diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 202507b2..4eda1970 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-21 12:40:42.216584","version":"3.41.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-21 20:27:26.235937","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8ef120e4..5c073f8c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,4 @@ Thumbs.db *.so *.dll *.exe -flutter_01.log +flutter_*.log diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 7ea3e77a..620eb209 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -10,6 +10,34 @@ class MongoService { final Map _connections = {}; + /// Returns a connected [MongoConnection] for [row], reusing an existing + /// pooled connection when it is already open for the same connection id. + /// + /// Use this from the sidebar and explorer so they share one socket per saved + /// connection instead of opening parallel clients. + Future ensureConnected(ConnectionRow row) async { + if (row.type != 'mongodb') { + throw ArgumentError('Connection type must be mongodb'); + } + final id = row.id ?? 0; + final existing = _connections[id]; + if (existing != null && existing.isConnected) { + return existing; + } + final connection = createConnection(row); + await connection.connect(); + return connection; + } + + /// Disconnects the pooled connection for [id], if any (e.g. when the user + /// removes the connection from the browser). + Future disconnectByConnectionId(int id) async { + final c = _connections[id]; + if (c != null) { + await disconnect(c); + } + } + /// 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) { diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index d7705d5f..1b968dbb 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -106,7 +106,15 @@ class PostgresConnection { if (_isConnected && _conn != null) return; try { if (_usesConnectionString) { - final parsed = parseConnectionString(connectionString!.trim()); + // Pool passes target catalog via [database]; URI alone would always open + // the DB embedded in the string — every tree branch then queried the + // same database (duplicate tables under finance / logistics, etc.). + final dbName = database ?? 'postgres'; + final uriForOpen = replaceDatabaseInConnectionString( + connectionString!.trim(), + dbName, + ); + final parsed = parseConnectionString(uriForOpen); final sslMode = parsed.sslMode ?? (useSSL ? SslMode.require : SslMode.disable); _conn = await Connection.open( diff --git a/lib/core/storage/folders_storage.dart b/lib/core/storage/folders_storage.dart index da9dc917..332f57c2 100644 --- a/lib/core/storage/folders_storage.dart +++ b/lib/core/storage/folders_storage.dart @@ -16,6 +16,7 @@ class FoldersStorage { List _folders = []; bool _loaded = false; + bool _migrationChecked = false; List get folders => List.unmodifiable(_folders); @@ -32,6 +33,8 @@ class FoldersStorage { } Future _migrateFromLegacyIfNeeded() async { + if (_migrationChecked) return; + _migrationChecked = true; try { final dir = await getApplicationSupportDirectory(); final sub = Directory('${dir.path}/querya_desktop'); @@ -75,4 +78,10 @@ class FoldersStorage { await LocalDb.instance.removeFolder(name); _folders = await LocalDb.instance.getFolders(); } + + /// Reloads folder names from [LocalDb] (e.g. after external seeding in tests). + Future reload() async { + _loaded = false; + await load(); + } } diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 60686d1f..7a98f868 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -7,6 +7,13 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const _dbName = 'querya.db'; const _dbVersion = 4; +int? _sqliteInt(Object? v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + return int.tryParse(v.toString()); +} + /// Local SQLite database for folders and connections. /// File: [applicationSupport]/querya_desktop/querya.db class LocalDb { @@ -14,6 +21,7 @@ class LocalDb { static final LocalDb instance = LocalDb._(); Database? _db; + String? _cachedDbPath; static Future initFfi() async { if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { @@ -24,12 +32,14 @@ class LocalDb { Future _open() async { if (_db != null && _db!.isOpen) return _db!; await initFfi(); - final dir = await getApplicationSupportDirectory(); - final sub = Directory(p.join(dir.path, 'querya_desktop')); - if (!await sub.exists()) await sub.create(recursive: true); - final path = p.join(sub.path, _dbName); + if (_cachedDbPath == null) { + final dir = await getApplicationSupportDirectory(); + final sub = Directory(p.join(dir.path, 'querya_desktop')); + if (!await sub.exists()) await sub.create(recursive: true); + _cachedDbPath = p.join(sub.path, _dbName); + } _db = await databaseFactoryFfi.openDatabase( - path, + _cachedDbPath!, options: OpenDatabaseOptions( version: _dbVersion, onCreate: _onCreate, @@ -173,7 +183,7 @@ class LocalDb { final db = await _open(); final rows = await db.query('folders', columns: ['id'], where: 'name = ?', whereArgs: [name]); if (rows.isEmpty) return null; - return rows.first['id'] as int?; + return _sqliteInt(rows.first['id']); } Future> getConnections() async { @@ -182,9 +192,10 @@ class LocalDb { return rows.map(ConnectionRow.fromMap).toList(); } - Future addConnection(ConnectionRow row) async { + /// Inserts a row and returns the SQLite row id. + Future addConnection(ConnectionRow row) async { final db = await _open(); - await db.insert('connections', row.toMap()); + return db.insert('connections', row.toMap()); } Future removeConnection(int id) async { @@ -248,19 +259,19 @@ class ConnectionRow { }; static ConnectionRow fromMap(Map m) => ConnectionRow( - id: m['id'] as int?, + id: _sqliteInt(m['id']), type: m['type'] as String, name: m['name'] as String, host: m['host'] as String?, - port: m['port'] as int?, + port: _sqliteInt(m['port']), username: m['username'] as String?, password: m['password'] as String?, databaseName: m['database_name'] as String?, authSource: m['auth_source'] as String?, - useSSL: (m['use_ssl'] as int?) == 1, + useSSL: _sqliteInt(m['use_ssl']) == 1, connectionString: m['connection_string'] as String?, - folderId: m['folder_id'] as int?, - sortOrder: m['sort_order'] as int? ?? 0, + folderId: _sqliteInt(m['folder_id']), + sortOrder: _sqliteInt(m['sort_order']) ?? 0, createdAt: m['created_at'] as String, ); } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index b4c794a9..3c5eca8c 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,6 +1,6 @@ -import 'package:flutter/material.dart' as material show BuildContext, Widget, Padding, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, Column, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, LayoutBuilder, TextPainter, TextSpan, TextDirection; +import 'package:flutter/material.dart' as material show AlertDialog, BoxConstraints, BuildContext, Column, ConstrainedBox, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, LayoutBuilder, TextPainter, TextSpan, TextDirection, SelectableText, Padding, Widget, Navigator, ValueKey; import 'package:flutter/services.dart' show Clipboard, ClipboardData; -import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; @@ -25,6 +25,11 @@ class ConnectionsPanel extends StatefulWidget { this.onMongoDBDatabaseSelected, this.onPostgresObjectSelected, this.onPostgresOpenSqlWorkspace, + /// When true, [initState] does not call [_loadData]. Widget tests that seed + /// SQLite in setUp should call [ConnectionsPanelState.reloadConnectionsFromDb] + /// inside [WidgetTester.runAsync] so only one load runs (avoids overlapping + /// sqflite isolate futures clobbering state under FakeAsync). + this.skipInitialDbLoadForTest = false, }); /// Called when the user taps a connection tile. @@ -50,23 +55,37 @@ class ConnectionsPanel extends StatefulWidget { /// Opens the PostgreSQL workspace home and switches to the SQL tab (e.g. from tree context menu). final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; + final bool skipInitialDbLoadForTest; + @override - State createState() => _ConnectionsPanelState(); + State createState() => ConnectionsPanelState(); } -class _ConnectionsPanelState extends State { +class ConnectionsPanelState extends State { List _folders = []; List _connections = []; Map _folderIdByName = {}; final Set _expandedFolders = {}; + /// Ignores stale [setState] when multiple [_loadData] runs overlap (e.g. tests). + int _loadDataGeneration = 0; @override void initState() { super.initState(); - _loadData(); + if (!widget.skipInitialDbLoadForTest) { + _loadData(); + } } + /// Reloads folders and connections from [LocalDb] / [FoldersStorage]. + /// + /// Widget tests should call this inside `WidgetTester.runAsync` so sqflite FFI + /// futures complete outside the test's FakeAsync zone (otherwise [initState]'s + /// [_loadData] may never reach [setState]). + Future reloadConnectionsFromDb() => _loadData(); + Future _loadData() async { + final gen = ++_loadDataGeneration; final folders = await FoldersStorage.instance.load(); var connections = await LocalDb.instance.getConnections(); // Remove stub connections (PostgreSQL/MySQL placeholders) from DB and from list @@ -79,18 +98,26 @@ class _ConnectionsPanelState extends State { final id = await LocalDb.instance.getFolderIdByName(name); if (id != null) folderIdByName[name] = id; } - if (mounted) { - setState(() { - final previousFolders = _folders.toSet(); - _folders = folders; - _connections = connections; - _folderIdByName = folderIdByName; - for (final name in folders) { - if (!previousFolders.contains(name)) _expandedFolders.add(name); - } - _expandedFolders.removeWhere((n) => !folders.contains(n)); - }); + if (!mounted || gen != _loadDataGeneration) { + return; } + setState(() { + final previousFolders = _folders.toSet(); + _folders = folders; + _connections = connections; + _folderIdByName = folderIdByName; + for (final name in folders) { + if (!previousFolders.contains(name)) { + // First load (no folders in state yet): expand all — matches old UX. + // Later, new folders stay collapsed so root connections stay visible + // and the tree does not look like catalogs moved under the folder. + if (previousFolders.isEmpty) { + _expandedFolders.add(name); + } + } + } + _expandedFolders.removeWhere((n) => !folders.contains(n)); + }); } static bool _isStubConnection(ConnectionRow c) { @@ -124,6 +151,7 @@ class _ConnectionsPanelState extends State { } Future _removeConnection(int id) async { + await MongoService.instance.disconnectByConnectionId(id); await LocalDb.instance.removeConnection(id); await _loadData(); } @@ -540,7 +568,7 @@ class _FolderTile extends StatelessWidget { : _ConnectionTile( connection: conn, icon: iconForType(conn.type), - iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), + iconAsset: ConnectionsPanelState._iconAssetForType(conn.type), onRemove: () => onRemoveConnection(conn.id!), onTap: () => onConnectionTap?.call(conn), ), @@ -906,22 +934,8 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { _error = null; }); try { - final c = widget.connection; - final conn = MongoConnection( - id: -1, - name: 'sidebar_probe', - host: c.host ?? 'localhost', - port: c.port ?? 27017, - username: c.username, - password: c.password, - database: c.databaseName, - authSource: c.authSource, - useSSL: c.useSSL, - connectionString: c.connectionString, - ); - await conn.connect(); + final conn = await MongoService.instance.ensureConnected(widget.connection); final dbs = await conn.listDatabases(); - await conn.disconnect(); if (!mounted) return; setState(() { @@ -946,23 +960,30 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { Future _deleteDatabase(String dbName) async { if (!mounted) return; + final ok = await showAppDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => material.AlertDialog( + title: const Text('Drop database?'), + content: Text( + 'Permanently delete database "$dbName"? This cannot be undone.', + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const Text('Drop database'), + ), + ], + ), + ); + if (ok != true || !mounted) return; try { - final c = widget.connection; - final conn = MongoConnection( - id: -1, - name: 'sidebar_probe', - host: c.host ?? 'localhost', - port: c.port ?? 27017, - username: c.username, - password: c.password, - database: c.databaseName, - authSource: c.authSource, - useSSL: c.useSSL, - connectionString: c.connectionString, - ); - await conn.connect(); + final conn = await MongoService.instance.ensureConnected(widget.connection); await conn.dropDatabase(dbName); - await conn.disconnect(); if (mounted) { _databases = []; @@ -1116,20 +1137,58 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { ), if (_error != null) material.Padding( - padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), + padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4, right: 8), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(maxWidth: double.infinity), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Icon( + material.Icons.error_outline_rounded, + size: 14, + color: theme.colorScheme.destructive, + ), + const Gap(6), + material.Expanded( + child: material.Text( + 'Could not load databases', + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + const Gap(6), + material.SelectableText( + _error!, + style: material.TextStyle( + fontSize: 10, + height: 1.35, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), ), ), for (final db in _databases) _MongoDatabaseNode( + connection: widget.connection, name: db, onTap: () => widget.onDatabaseTap?.call(db), onDelete: () => _deleteDatabase(db), + onRefreshDatabases: () { + setState(() => _databases = []); + _loadDatabases(); + }, ), ], ], @@ -1141,61 +1200,40 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { class _MongoDatabaseNode extends StatelessWidget { const _MongoDatabaseNode({ + required this.connection, required this.name, required this.onTap, required this.onDelete, + required this.onRefreshDatabases, }); + final ConnectionRow connection; final String name; final VoidCallback onTap; final VoidCallback onDelete; + final VoidCallback onRefreshDatabases; @override Widget build(BuildContext context) { final theme = Theme.of(context); - return ContextMenu( - items: [ - MenuButton( - leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) => onDelete(), - child: const Text('Delete database'), - ), - ], - child: material.Padding( - padding: const material.EdgeInsets.only(left: 24), - child: material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: onTap, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, vertical: 5), - child: material.Row( - children: [ - material.Icon( - material.Icons.storage_rounded, - size: 14, - color: theme.colorScheme.primary.withValues(alpha: 0.7), - ), - const Gap(8), - material.Expanded( - child: material.Text( - name, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - ), - ), - ], - ), - ), - ), + return material.Padding( + padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), + child: _PgTreeRow( + label: name, + icon: material.Icons.storage_rounded, + iconSize: 13, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, ), + verticalPadding: 3, + onTap: onTap, + connection: connection, + onContextRefresh: onRefreshDatabases, + onOpenSqlWorkspace: null, + onContextDelete: onDelete, + contextDeleteLabel: 'Delete database', ), ); } @@ -1516,6 +1554,8 @@ class _PgTreeRow extends material.StatelessWidget { this.connection, this.onContextRefresh, this.onOpenSqlWorkspace, + this.onContextDelete, + this.contextDeleteLabel, }); final String label; @@ -1530,6 +1570,8 @@ class _PgTreeRow extends material.StatelessWidget { final ConnectionRow? connection; final VoidCallback? onContextRefresh; final void Function(ConnectionRow connection)? onOpenSqlWorkspace; + final VoidCallback? onContextDelete; + final String? contextDeleteLabel; @override material.Widget build(material.BuildContext context) { @@ -1609,6 +1651,16 @@ class _PgTreeRow extends material.StatelessWidget { onPressed: (_) => onOpenSqlWorkspace!(connection!), child: const Text('Open in SQL'), ), + if (onContextDelete != null) + MenuButton( + leading: material.Icon( + material.Icons.delete_outline_rounded, + size: 18, + color: theme.colorScheme.destructive, + ), + onPressed: (_) => onContextDelete!(), + child: Text(contextDeleteLabel ?? 'Delete'), + ), ], child: row, ); @@ -1654,6 +1706,7 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { if (_expanded) for (final db in widget.databases) _PgDatabaseNode( + key: material.ValueKey('pg-db-${widget.connection.id ?? 0}-$db'), connection: widget.connection, databaseName: db, onPostgresObjectSelected: widget.onPostgresObjectSelected, @@ -1667,6 +1720,7 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { class _PgDatabaseNode extends StatefulWidget { const _PgDatabaseNode({ + super.key, required this.connection, required this.databaseName, this.onPostgresObjectSelected, @@ -1941,6 +1995,9 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { if (_expanded) for (final schema in widget.schemas) _PgSchemaNode( + key: material.ValueKey( + 'pg-schema-${widget.connection.id ?? 0}-${widget.databaseName}-$schema', + ), connection: widget.connection, databaseName: widget.databaseName, schemaName: schema, @@ -1955,6 +2012,7 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { class _PgSchemaNode extends StatefulWidget { const _PgSchemaNode({ + super.key, required this.connection, required this.databaseName, required this.schemaName, diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 071cede1..4594365b 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -1,3 +1,5 @@ +import 'dart:math' show min; + 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'; @@ -11,22 +13,35 @@ class MongoCollectionsView extends material.StatefulWidget { required this.connection, required this.database, this.onCollectionTap, + this.refreshToken = 0, }); final MongoConnection connection; final String database; final ValueChanged? onCollectionTap; + /// Incremented by the parent when the user requests a refresh (toolbar). + final int refreshToken; + @override material.State createState() => _MongoCollectionsViewState(); } +/// Parallel [collStats] calls per batch (limits load on large clusters). +const _statsConcurrency = 6; + class _MongoCollectionsViewState extends material.State { List<_CollectionInfo> _collections = []; bool _loading = true; String? _error; + /// Incremented on each full reload so in-flight stats work is ignored after dispose / new load. + int _loadGeneration = 0; + bool _loadingStats = false; + int _statsProgress = 0; + int _statsTotal = 0; + final _newCollController = material.TextEditingController(); @override @@ -35,47 +50,90 @@ class _MongoCollectionsViewState extends material.State { _load(); } + @override + void didUpdateWidget(covariant MongoCollectionsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.refreshToken != widget.refreshToken) { + _load(); + } + } + @override void dispose() { + _loadGeneration++; _newCollController.dispose(); super.dispose(); } Future _load() async { + final gen = ++_loadGeneration; if (!mounted) return; setState(() { _loading = true; _error = null; + _loadingStats = false; + _statsProgress = 0; + _statsTotal = 0; }); 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; + if (!mounted || gen != _loadGeneration) return; + setState(() { - _collections = collections; + _collections = [ + for (final n in names) + _CollectionInfo(name: n, documentCount: null, size: null), + ]; _loading = false; + _loadingStats = names.isNotEmpty; + _statsTotal = names.length; + _statsProgress = 0; }); + + if (names.isEmpty) return; + + for (var i = 0; i < names.length; i += _statsConcurrency) { + if (!mounted || gen != _loadGeneration) return; + final end = min(i + _statsConcurrency, names.length); + final chunk = names.sublist(i, end); + + final chunkInfos = await Future.wait( + chunk.map((name) async { + try { + final stats = await MongoService.instance.getCollectionStats( + widget.connection, + widget.database, + name, + ); + return _CollectionInfo( + name: name, + documentCount: _toInt(stats['count']), + size: _toInt(stats['size']), + ); + } catch (_) { + return _CollectionInfo(name: name, documentCount: null, size: null); + } + }), + ); + + if (!mounted || gen != _loadGeneration) return; + setState(() { + for (var k = 0; k < chunk.length; k++) { + _collections[i + k] = chunkInfos[k]; + } + _statsProgress = end; + if (end >= names.length) { + _loadingStats = false; + } + }); + } } catch (e) { - if (mounted) { + if (mounted && gen == _loadGeneration) { setState(() { _error = e.toString(); _loading = false; + _loadingStats = false; }); } } @@ -210,9 +268,26 @@ class _MongoCollectionsViewState extends material.State { material.Icon(material.Icons.folder_rounded, size: 18, color: shadcnCs.primary), const Gap(10), - Text('${widget.database} — Collections (${_collections.length})') - .semiBold(), - const Spacer(), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text( + '${widget.database} — Collections (${_collections.length})', + ).semiBold(), + if (_loadingStats && _statsTotal > 0) + material.Padding( + padding: const material.EdgeInsets.only(top: 4), + child: Text( + 'Loading stats $_statsProgress / $_statsTotal…', + ) + .muted() + .xSmall(), + ), + ], + ), + ), material.SizedBox( width: 180, child: TextField( diff --git a/lib/features/mongodb/mongo_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index cdbddae1..8630995b 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -12,6 +12,7 @@ class MongoDatabasesView extends StatefulWidget { required this.connectionRow, this.connection, this.onDatabaseTap, + this.refreshToken = 0, }); final ConnectionRow connectionRow; @@ -23,6 +24,9 @@ class MongoDatabasesView extends StatefulWidget { /// Called when the user taps a database row to browse it. final ValueChanged? onDatabaseTap; + /// Incremented by the parent when the user requests a refresh (toolbar). + final int refreshToken; + @override State createState() => _MongoDatabasesViewState(); } @@ -47,6 +51,8 @@ class _MongoDatabasesViewState extends State { if (oldWidget.connectionRow.id != widget.connectionRow.id || oldWidget.connection != widget.connection) { _connectAndLoad(); + } else if (oldWidget.refreshToken != widget.refreshToken) { + _loadDatabases(); } } @@ -66,10 +72,14 @@ class _MongoDatabasesViewState extends State { // 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(); + final MongoConnection conn; + if (widget.connection != null) { + conn = widget.connection!; + if (!conn.isConnected) { + await conn.connect(); + } + } else { + conn = await MongoService.instance.ensureConnected(widget.connectionRow); } if (!mounted) return; _connection = conn; diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index beffba39..5f9bd4b2 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -14,6 +14,7 @@ class MongoDocumentEditor extends material.StatefulWidget { required this.database, required this.collection, required this.document, + this.refreshToken = 0, this.onBack, this.onDocumentUpdated, this.onDocumentDeleted, @@ -23,6 +24,10 @@ class MongoDocumentEditor extends material.StatefulWidget { final String database; final String collection; final Map document; + + /// Incremented by the parent when the user requests a refresh (toolbar). + final int refreshToken; + final VoidCallback? onBack; final VoidCallback? onDocumentUpdated; final VoidCallback? onDocumentDeleted; @@ -49,6 +54,49 @@ class _MongoDocumentEditorState extends material.State { _controller.addListener(_onTextChanged); } + @override + void didUpdateWidget(covariant MongoDocumentEditor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.refreshToken != widget.refreshToken) { + _reloadFromServer(); + } + } + + /// Fetches the latest document from the server (toolbar Refresh). + Future _reloadFromServer() async { + final id = widget.document['_id']; + if (id == null) return; + setState(() { + _error = null; + _success = null; + }); + try { + final rows = await MongoService.instance.find( + widget.connection, + widget.database, + widget.collection, + filter: {'_id': id}, + limit: 1, + ); + if (!mounted) return; + if (rows.isEmpty) { + setState(() => _error = 'Document no longer exists'); + return; + } + final doc = rows.first; + _controller.removeListener(_onTextChanged); + _controller.text = _prettyJson(doc); + _controller.addListener(_onTextChanged); + setState(() { + _dirty = false; + }); + } catch (e) { + if (mounted) { + setState(() => _error = 'Failed to reload: $e'); + } + } + } + @override void dispose() { _controller.removeListener(_onTextChanged); diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index 0ed8782e..c6f06344 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -16,6 +16,7 @@ class MongoDocumentsView extends material.StatefulWidget { required this.database, required this.collection, this.onDocumentTap, + this.refreshToken = 0, }); final MongoConnection connection; @@ -23,6 +24,9 @@ class MongoDocumentsView extends material.StatefulWidget { final String collection; final ValueChanged>? onDocumentTap; + /// Incremented by the parent when the user requests a refresh (toolbar). + final int refreshToken; + @override material.State createState() => _MongoDocumentsViewState(); @@ -45,6 +49,14 @@ class _MongoDocumentsViewState extends material.State { _load(); } + @override + void didUpdateWidget(covariant MongoDocumentsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.refreshToken != widget.refreshToken) { + _load(); + } + } + @override void dispose() { _filterController.dispose(); diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index efe1798a..70d465ec 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -47,6 +47,9 @@ class _MongoExplorerViewState extends material.State { // View mode bool _showStats = false; + /// Bumped when the user taps Refresh in the breadcrumb bar (reload active view). + int _refreshToken = 0; + // Navigation state String? _selectedDatabase; String? _selectedCollection; @@ -65,27 +68,24 @@ class _MongoExplorerViewState extends material.State { void didUpdateWidget(covariant MongoExplorerView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { - _disconnectCurrent(); + _clearLocalConnectionRef(); _connect(); } } @override void dispose() { - _disconnectCurrent(); + _connection = null; super.dispose(); } - void _disconnectCurrent() { - final conn = _connection; + /// Clears the local reference only; pooled sockets stay in [MongoService]. + void _clearLocalConnectionRef() { _connection = null; - if (conn != null) { - conn.disconnect(); - } } Future _connect() async { - _disconnectCurrent(); + _clearLocalConnectionRef(); if (!mounted) return; setState(() { _connecting = true; @@ -96,10 +96,8 @@ class _MongoExplorerViewState extends material.State { }); try { final conn = - MongoService.instance.createConnection(widget.connectionRow); - await conn.connect(); + await MongoService.instance.ensureConnected(widget.connectionRow); if (!mounted) { - conn.disconnect(); return; } setState(() { @@ -268,10 +266,7 @@ class _MongoExplorerViewState extends material.State { _BreadcrumbBar( crumbs: _crumbs, onCrumbTap: _onCrumbTap, - onRefresh: () { - // Force rebuild of current child - setState(() {}); - }, + onRefresh: () => setState(() => _refreshToken++), onStats: () => setState(() => _showStats = true), ), const Divider(height: 1), @@ -293,6 +288,7 @@ class _MongoExplorerViewState extends material.State { database: _selectedDatabase!, collection: _selectedCollection!, document: _selectedDocument!, + refreshToken: _refreshToken, onBack: _navigateToDocuments, onDocumentUpdated: _navigateToDocuments, onDocumentDeleted: _navigateToDocuments, @@ -306,6 +302,7 @@ class _MongoExplorerViewState extends material.State { connection: conn, database: _selectedDatabase!, collection: _selectedCollection!, + refreshToken: _refreshToken, onDocumentTap: _navigateToDocument, ); } @@ -316,6 +313,7 @@ class _MongoExplorerViewState extends material.State { key: ValueKey('colls_$_selectedDatabase'), connection: conn, database: _selectedDatabase!, + refreshToken: _refreshToken, onCollectionTap: _navigateToCollection, ); } @@ -325,6 +323,7 @@ class _MongoExplorerViewState extends material.State { key: ValueKey(widget.connectionRow.id), connection: conn, connectionRow: widget.connectionRow, + refreshToken: _refreshToken, onDatabaseTap: _navigateToDatabase, ); } diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index f3927e7a..0a029b5e 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -7,7 +7,7 @@ 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 _defaultAutoRefresh = Duration(seconds: 3); const _summaryChipHeight = 72.0; const _gridCardHeight = 220.0; @@ -39,6 +39,12 @@ class _MongoStatsViewState extends material.State { String? _error; Timer? _timer; + /// `null` = auto-refresh off. Default matches previous 3s polling. + Duration? _autoRefreshInterval = _defaultAutoRefresh; + + DateTime? _lastFetchedAt; + bool _manualRefreshing = false; + @override void initState() { super.initState(); @@ -62,17 +68,9 @@ 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. + /// Clears the local connection reference (pooled socket stays in [MongoService]). void _disconnectCurrent() { - final conn = _connection; _connection = null; - if (conn != null && _ownsConnection) { - conn.disconnect(); // fire-and-forget; disconnect handles errors - } - _ownsConnection = false; } Future _load() async { @@ -87,17 +85,13 @@ class _MongoStatsViewState extends material.State { try { // Re-use the connection supplied by the parent when available. final supplied = widget.connection; - MongoConnection conn; + final MongoConnection conn; if (supplied != null && supplied.isConnected) { conn = supplied; - _ownsConnection = false; } else { - conn = MongoService.instance.createConnection(widget.connectionRow); - await conn.connect(); - _ownsConnection = true; + conn = await MongoService.instance.ensureConnected(widget.connectionRow); } if (!mounted) { - if (_ownsConnection) conn.disconnect(); return; } _connection = conn; @@ -126,27 +120,62 @@ class _MongoStatsViewState extends material.State { setState(() { _serverStatus = status; _loading = false; + _lastFetchedAt = DateTime.now(); }); } catch (e) { - if (mounted) setState(() { _error = e.toString(); _loading = false; }); + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + /// Fetches [serverStatus] only (no full reconnect). Use from toolbar / pull-to-refresh. + Future _refreshNow() async { + final c = _connection; + if (c == null || !c.isConnected) { + await _load(); + return; + } + setState(() => _manualRefreshing = true); + try { + await _fetch(); + } finally { + if (mounted) setState(() => _manualRefreshing = false); + } + } + + Future _pollTick() 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; + _lastFetchedAt = DateTime.now(); + }); + } catch (_) { + // Keep last good snapshot on transient errors during auto-refresh. } } 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 (_) {} - }); + final interval = _autoRefreshInterval; + if (interval == null) return; + _timer = Timer.periodic(interval, (_) => _pollTick()); + } + + String _formatClock(DateTime t) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}'; } @override @@ -202,7 +231,7 @@ class _MongoStatsViewState extends material.State { return material.Container( color: cs.background, child: material.RefreshIndicator( - onRefresh: _fetch, + onRefresh: _refreshNow, child: material.SingleChildScrollView( physics: const material.AlwaysScrollableScrollPhysics(), padding: const material.EdgeInsets.all(24), @@ -251,7 +280,11 @@ class _MongoStatsViewState extends material.State { material.Widget _header(material.BuildContext context) { final cs = shadcn.Theme.of(context).colorScheme; - return material.Row( + final last = _lastFetchedAt; + return material.Wrap( + crossAxisAlignment: material.WrapCrossAlignment.center, + spacing: 8, + runSpacing: 10, children: [ material.Container( padding: const material.EdgeInsets.all(10), @@ -261,8 +294,8 @@ class _MongoStatsViewState extends material.State { ), child: material.Icon(material.Icons.eco_rounded, size: 28, color: cs.primary), ), - const Gap(16), - material.Expanded( + material.ConstrainedBox( + constraints: const material.BoxConstraints(minWidth: 160, maxWidth: 400), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, @@ -270,11 +303,16 @@ class _MongoStatsViewState extends material.State { Text(widget.connectionRow.name).large().semiBold(), const Gap(4), Text('${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 27017}') - .muted().small(), + .muted() + .small(), + if (last != null) ...[ + const Gap(4), + Text('Last updated ${_formatClock(last)}').muted().xSmall(), + ], ], ), ), - if (widget.onBack != null) ...[ + if (widget.onBack != null) OutlineButton( onPressed: widget.onBack, leading: const material.Icon( @@ -282,13 +320,51 @@ class _MongoStatsViewState extends material.State { size: 18), child: const Text('Explorer'), ), - const Gap(8), - ], OutlineButton( - onPressed: _load, - leading: const material.Icon( - material.Icons.refresh_rounded, size: 18), - child: const Text('Refresh'), + onPressed: _manualRefreshing ? null : _refreshNow, + leading: _manualRefreshing + ? const material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator(strokeWidth: 2), + ) + : const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Refresh now'), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4), + child: material.DropdownButton( + value: _autoRefreshInterval, + underline: const material.SizedBox.shrink(), + isDense: true, + borderRadius: material.BorderRadius.circular(8), + items: const [ + material.DropdownMenuItem( + value: null, + child: material.Text('Auto: off'), + ), + material.DropdownMenuItem( + value: Duration(seconds: 3), + child: material.Text('Auto: 3 s'), + ), + material.DropdownMenuItem( + value: Duration(seconds: 10), + child: material.Text('Auto: 10 s'), + ), + material.DropdownMenuItem( + value: Duration(seconds: 30), + child: material.Text('Auto: 30 s'), + ), + material.DropdownMenuItem( + value: Duration(seconds: 60), + child: material.Text('Auto: 60 s'), + ), + ], + onChanged: (value) { + setState(() => _autoRefreshInterval = value); + _startTimer(); + }, + ), ), ], ); diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 77c87269..8b450e56 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -457,27 +457,31 @@ class _SqlToolbar extends material.StatelessWidget { items: const [ material.DropdownMenuItem( value: null, - child: Text('Default'), + child: material.Text('No limit'), + ), + material.DropdownMenuItem( + value: 10, + child: material.Text('10 s'), ), material.DropdownMenuItem( value: 30, - child: Text('30 s'), + child: material.Text('30 s'), ), material.DropdownMenuItem( value: 60, - child: Text('60 s'), + child: material.Text('60 s'), ), material.DropdownMenuItem( value: 120, - child: Text('120 s'), + child: material.Text('2 min'), ), material.DropdownMenuItem( value: 300, - child: Text('5 min'), + child: material.Text('5 min'), ), material.DropdownMenuItem( value: 600, - child: Text('10 min'), + child: material.Text('10 min'), ), ], ), diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index 0bef9b3a..cdfbbfa5 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -20,18 +20,14 @@ Future showAppDialog({ return _BlurredDialogScaffold( barrierDismissible: barrierDismissible, onDismiss: () => Navigator.of(ctx).pop(), + animation: animation, child: builder(ctx), ); }, - transitionBuilder: (context, animation, secondaryAnimation, child) { - return FadeTransition( - opacity: CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ), - child: child, - ); - }, + // Pass through — all animation is handled inside _BlurredDialogScaffold. + // Do NOT wrap the whole page (including BackdropFilter) in a FadeTransition: + // that made blur invisible at opacity=0 and caused it to pop in with a delay. + transitionBuilder: (context, animation, secondaryAnimation, child) => child, ); } @@ -39,35 +35,62 @@ class _BlurredDialogScaffold extends StatelessWidget { const _BlurredDialogScaffold({ required this.barrierDismissible, required this.onDismiss, + required this.animation, required this.child, }); final bool barrierDismissible; final VoidCallback onDismiss; + final Animation animation; final Widget child; + // Eased curve for dialog card fade-in / scale-up. + static final _curve = CurveTween(curve: Curves.easeOutCubic); + @override Widget build(BuildContext context) { + final curved = animation.drive(_curve); return Material( type: MaterialType.transparency, child: Stack( fit: StackFit.expand, children: [ + // ── Backdrop: animates blur sigma and dim alpha directly, without + // being wrapped in a FadeTransition, so blur starts immediately. Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: barrierDismissible ? onDismiss : null, - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Container( - color: Colors.black.withValues(alpha: 0.32), - ), + child: AnimatedBuilder( + animation: curved, + builder: (ctx, _) { + final t = curved.value; + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10.0 * t, sigmaY: 10.0 * t), + child: Container( + color: Colors.black.withValues(alpha: 0.32 * t), + ), + ), + ); + }, + ), + ), + ), + // ── Dialog card: fades + scales up, independently of the backdrop. + Center( + child: AnimatedBuilder( + animation: curved, + builder: (ctx, inner) => FadeTransition( + opacity: curved, + child: ScaleTransition( + scale: Tween(begin: 0.92, end: 1.0).animate(curved), + child: inner, ), ), + child: child, ), ), - Center(child: child), ], ), ); diff --git a/test/features/connections/connections_panel_layout_test.dart b/test/features/connections/connections_panel_layout_test.dart index 8099fa30..fadb91a7 100644 --- a/test/features/connections/connections_panel_layout_test.dart +++ b/test/features/connections/connections_panel_layout_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; @@ -10,6 +11,20 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/layout_overflow.dart'; +String _isoNow() => DateTime.now().toUtc().toIso8601String(); + +/// Avoid [findsOneWidget] here: it polls until a long timeout and can hang the suite. +/// Use [find.text] (matches Material [Text] via RichText) — [find.widgetWithText] +/// with `material.Text` can miss the same widget class across import boundaries. +void _expectTextCount(String label, int expected) { + final n = find.text(label).evaluate().length; + expect( + n, + expected, + reason: 'find.text("$label"): expected $expected, found $n', + ); +} + class _FakePathProvider extends PathProviderPlatform { _FakePathProvider(this._root); final String _root; @@ -52,6 +67,13 @@ void main() { tempDir = await Directory.systemTemp.createTemp('querya_conn_panel_test_'); PathProviderPlatform.instance = _FakePathProvider(tempDir.path); await LocalDb.initFfi(); + // LocalDb is a singleton: if it was already opened in this isolate (e.g. another + // test file ran first), the DB path would be wrong. Force reopen with fake path. + await LocalDb.instance.close(); + // Pre-load FoldersStorage so _migrateFromLegacyIfNeeded() (which uses + // dart:io File.exists()) runs here in real async — it would hang inside + // testWidgets' FakeAsync zone. + await FoldersStorage.instance.reload(); }); tearDownAll(() async { @@ -90,4 +112,119 @@ void main() { }); } }); + + group('ConnectionsPanel expanded folder (tree)', () { + // ALL data seeding happens in setUp which runs in REAL async (outside + // FakeAsync). This is critical: FoldersStorage.reload() internally calls + // File.exists() (dart:io) which never completes in FakeAsync, and sqflite + // FFI query Futures also need real-zone microtask processing. + setUp(() async { + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.instance.close(); + + await LocalDb.instance.addFolder('LayoutTestFolder'); + final folderId = + await LocalDb.instance.getFolderIdByName('LayoutTestFolder'); + + await LocalDb.instance.addConnection( + ConnectionRow( + type: 'postgresql', + name: 'PG local', + host: '127.0.0.1', + port: 5432, + createdAt: _isoNow(), + folderId: folderId, + ), + ); + await LocalDb.instance.addConnection( + ConnectionRow( + type: 'redis', + name: 'Redis local', + host: '127.0.0.1', + port: 6379, + createdAt: _isoNow(), + folderId: folderId, + ), + ); + await LocalDb.instance.addConnection( + ConnectionRow( + type: 'mongodb', + name: 'Mongo local', + host: '127.0.0.1', + port: 27017, + createdAt: _isoNow(), + folderId: folderId, + ), + ); + await FoldersStorage.instance.reload(); + + // Keep LocalDb open: closing here and reopening during testWidgets (FakeAsync) + // often prevents _loadData() from finishing setState — FoldersStorage cache + // is correct but the panel stays empty (no PG rows). Cross-zone sqflite + // workarounds use runAsync+pump in the test body instead. + }); + + tearDown(() async { + final conns = await LocalDb.instance.getConnections(); + for (final c in conns) { + if (c.id != null) await LocalDb.instance.removeConnection(c.id!); + } + for (final name in await LocalDb.instance.getFolders()) { + await LocalDb.instance.removeFolder(name); + } + await FoldersStorage.instance.reload(); + }); + + testWidgets( + 'narrow panel: expanded folder lists pg / redis / mongo rows without overflow', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(320, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.SizedBox.expand( + child: ConnectionsPanel( + skipInitialDbLoadForTest: true, + onPostgresOpenSqlWorkspace: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + // One [_loadData] only: skip initState load + reload inside runAsync (real + // async). Two overlapping loads under FakeAsync left the panel empty even + // when FoldersStorage had folders (stale sqflite futures + generation guard). + final panelState = tester.state( + find.byType(ConnectionsPanel), + ); + Object? reloadError; + await tester.runAsync(() async { + try { + await panelState.reloadConnectionsFromDb(); + } catch (e) { + reloadError = e; + } + // Let completions scheduled from the isolate chain run before runAsync ends. + await Future.delayed(const Duration(milliseconds: 1)); + }); + expect( + reloadError, + isNull, + reason: + 'reloadConnectionsFromDb threw (runAsync often swallows async errors): $reloadError', + ); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + + _expectTextCount('LayoutTestFolder', 1); + _expectTextCount('PG local', 1); + _expectTextCount('Redis local', 1); + _expectTextCount('Mongo local', 1); + }); + }); } diff --git a/test/support/layout_overflow.dart b/test/support/layout_overflow.dart index a4030023..fd88897d 100644 --- a/test/support/layout_overflow.dart +++ b/test/support/layout_overflow.dart @@ -9,6 +9,19 @@ bool isLayoutOverflowError(FlutterErrorDetails details) { s.contains('RenderFlex'); } +/// Bounded frame pumping — avoids [WidgetTester.pumpAndSettle], which never returns +/// when the tree has a never-ending animation (e.g. [CircularProgressIndicator], +/// shimmer, or a continuous implicit animation). +Future pumpFrames( + WidgetTester tester, { + int count = 120, + Duration step = const Duration(milliseconds: 50), +}) async { + for (var i = 0; i < count; i++) { + await tester.pump(step); + } +} + /// Pumps [widget] with [tester.binding.setSurfaceSize], restores size in tearDown. Future pumpWidgetWithSurfaceSize( WidgetTester tester, @@ -18,7 +31,7 @@ Future pumpWidgetWithSurfaceSize( await tester.binding.setSurfaceSize(size); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget(widget); - await tester.pumpAndSettle(); + await pumpFrames(tester); } /// Runs [action], collecting overflow-like [FlutterErrorDetails] while still