diff --git a/lib/features/connections/connection_databases_folder.dart b/lib/features/connections/connection_databases_folder.dart new file mode 100644 index 00000000..ce6df46c --- /dev/null +++ b/lib/features/connections/connection_databases_folder.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' + show Clipboard, ClipboardData, LogicalKeyboardKey; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; +import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Collapsible **Databases (N)** folder chrome for network drivers +/// (PostgreSQL / MySQL / MongoDB / Redis). +/// +/// Defaults to expanded; uses [QueryaMotion.treeExpand] for the chevron and +/// [QueryaAnimatedExpand] for the child list. +class ConnectionDatabasesFolder extends material.StatefulWidget { + const ConnectionDatabasesFolder({ + super.key, + required this.connection, + required this.databaseCount, + required this.child, + this.onRefresh, + this.onOpenSql, + this.initiallyExpanded = true, + }); + + final ConnectionRow connection; + final int databaseCount; + final material.Widget child; + final material.VoidCallback? onRefresh; + + /// Optional "Open in SQL" context action (PG / MySQL). + final material.VoidCallback? onOpenSql; + + final bool initiallyExpanded; + + @override + material.State createState() => + _ConnectionDatabasesFolderState(); +} + +class _ConnectionDatabasesFolderState + extends material.State { + late bool _expanded = widget.initiallyExpanded; + + @override + void didUpdateWidget(covariant ConnectionDatabasesFolder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initiallyExpanded != widget.initiallyExpanded && + oldWidget.databaseCount == 0 && + widget.databaseCount > 0) { + _expanded = widget.initiallyExpanded; + } + } + + void _toggle() => setState(() => _expanded = !_expanded); + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final primary = theme.colorScheme.primary; + final muted = theme.colorScheme.mutedForeground; + final label = 'Databases (${widget.databaseCount})'; + + final row = material.CallbackShortcuts( + bindings: { + if (!_expanded) + const material.SingleActivator(LogicalKeyboardKey.arrowRight): _toggle, + if (_expanded) + const material.SingleActivator(LogicalKeyboardKey.arrowLeft): _toggle, + }, + child: material.AnimatedContainer( + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), + decoration: material.BoxDecoration( + color: material.Colors.transparent, + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + hoverColor: primary.withValues(alpha: 0.07), + focusColor: primary.withValues(alpha: 0.14), + splashColor: primary.withValues(alpha: 0.10), + highlightColor: primary.withValues(alpha: 0.05), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + child: material.Row( + children: [ + material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, + color: muted, + ), + ), + const Gap(4), + material.Icon( + QueryaIcons.databasesFolder, + size: QueryaIconSizes.treeConnection, + color: primary.withValues(alpha: 0.7), + ), + const Gap(6), + material.Expanded( + child: material.Text( + label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + + final menu = ContextMenu( + items: [ + if (widget.onRefresh != null) + MenuButton( + leading: material.Icon( + material.Icons.refresh_rounded, + size: 18, + color: muted, + ), + onPressed: (_) => widget.onRefresh!(), + child: const Text('Refresh'), + ), + MenuButton( + leading: material.Icon( + material.Icons.copy_rounded, + size: 18, + color: muted, + ), + onPressed: (_) { + Clipboard.setData(ClipboardData(text: label)); + }, + child: const Text('Copy name'), + ), + if (widget.onOpenSql != null) + MenuButton( + leading: material.Icon( + material.Icons.terminal_rounded, + size: 18, + color: muted, + ), + onPressed: (_) => widget.onOpenSql!(), + child: const Text('Open in SQL'), + ), + ], + child: row, + ); + + return material.Padding( + padding: const material.EdgeInsets.only( + left: QueryaTreeTokens.underConnection, + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Semantics( + button: true, + expanded: _expanded, + label: label, + child: menu, + ), + QueryaAnimatedExpand( + expanded: _expanded, + child: widget.child, + ), + ], + ), + ); + } +} diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 5dc02844..c9edab1c 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -80,6 +80,7 @@ import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/core/ui/querya_tooltip.dart'; import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/connection_databases_folder.dart'; import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index a294216b..9f6aaebe 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -327,42 +327,23 @@ class _MongoDatabasesNode extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: QueryaTreeTokens.underConnection), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: 'Databases (${databases.length})', - icon: QueryaIcons.databasesFolder, - iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - onTap: null, + return ConnectionDatabasesFolder( + connection: connection, + databaseCount: databases.length, + onRefresh: onRefreshDatabases, + child: lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _MongoDatabaseNode( connection: connection, - onContextRefresh: onRefreshDatabases, - ), - lazyConnectionTreeList( - context: context, - itemCount: databases.length, - itemBuilder: (context, index) { - final db = databases[index]; - return _MongoDatabaseNode( - connection: connection, - name: db, - onTap: () => onDatabaseTap?.call(db), - onDelete: () => onDeleteDatabase(db), - onRefreshDatabases: onRefreshDatabases, - ); - }, - ), - ], + name: db, + onTap: () => onDatabaseTap?.call(db), + onDelete: () => onDeleteDatabase(db), + onRefreshDatabases: onRefreshDatabases, + ); + }, ), ); } diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 8fc46a4b..3cf01782 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -295,46 +295,26 @@ class _MysqlDatabasesNode extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: QueryaTreeTokens.underConnection), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: 'Databases (${databases.length})', - icon: QueryaIcons.databasesFolder, - iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - onTap: null, + return ConnectionDatabasesFolder( + connection: connection, + databaseCount: databases.length, + onRefresh: onRefreshDatabases, + onOpenSql: onMysqlOpenSqlWorkspace == null + ? null + : () => onMysqlOpenSqlWorkspace!(connection), + child: lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _MysqlDatabaseNode( + key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), connection: connection, - onContextRefresh: onRefreshDatabases, - onOpenSqlWorkspace: onMysqlOpenSqlWorkspace == null - ? null - : (c, {database, schema, name, kind}) => - onMysqlOpenSqlWorkspace!(c), - ), - lazyConnectionTreeList( - context: context, - itemCount: databases.length, - itemBuilder: (context, index) { - final db = databases[index]; - return _MysqlDatabaseNode( - key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), - connection: connection, - databaseName: db, - onMysqlObjectSelected: onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, - ); - }, - ), - ], + databaseName: db, + onMysqlObjectSelected: onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, + ); + }, ), ); } diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 68f1ba8d..3b7c714f 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -1,6 +1,6 @@ part of 'package:querya_desktop/features/connections/connections_panel.dart'; -class _PgDatabasesNode extends StatefulWidget { +class _PgDatabasesNode extends StatelessWidget { const _PgDatabasesNode({ required this.connection, required this.databases, @@ -22,7 +22,30 @@ class _PgDatabasesNode extends StatefulWidget { final VoidCallback onRefreshDatabases; @override - State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); + Widget build(BuildContext context) { + return ConnectionDatabasesFolder( + connection: connection, + databaseCount: databases.length, + onRefresh: onRefreshDatabases, + onOpenSql: onPostgresOpenSqlWorkspace == null + ? null + : () => onPostgresOpenSqlWorkspace!(connection), + child: lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _PgDatabaseNode( + key: material.ValueKey('pg-db-${connection.id ?? 0}-$db'), + connection: connection, + databaseName: db, + onPostgresObjectSelected: onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: onPostgresOpenSqlWorkspace, + ); + }, + ), + ); + } } /// Ellipsis label; tooltip when the name is long enough to likely truncate. @@ -325,68 +348,6 @@ class _PgTreeRow extends material.StatelessWidget { } } -class _PgDatabasesNodeState extends State<_PgDatabasesNode> { - bool _expanded = true; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: QueryaTreeTokens.underConnection), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: 'Databases (${widget.databases.length})', - leading: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.treeExpand), - curve: context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.treeExpand, - color: theme.colorScheme.mutedForeground, - ), - ), - icon: QueryaIcons.databasesFolder, - iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - expanded: _expanded, - onTap: () => setState(() => _expanded = !_expanded), - connection: widget.connection, - onContextRefresh: widget.onRefreshDatabases, - onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), - QueryaAnimatedExpand( - expanded: _expanded, - child: lazyConnectionTreeList( - context: context, - itemCount: widget.databases.length, - itemBuilder: (context, index) { - final db = widget.databases[index]; - return _PgDatabaseNode( - key: material.ValueKey( - 'pg-db-${widget.connection.id ?? 0}-$db'), - connection: widget.connection, - databaseName: db, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ); - }, - ), - ), - ], - ), - ); - } -} - class _PgDatabaseNode extends StatefulWidget { const _PgDatabaseNode({ super.key, diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 7aee5880..6201cdc8 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -301,41 +301,22 @@ class _RedisDatabasesNode extends material.StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return material.Padding( - padding: const material.EdgeInsets.only(left: QueryaTreeTokens.underConnection), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - _PgTreeRow( - label: 'Databases (${databases.length})', - icon: QueryaIcons.databasesFolder, - iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), - textStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.foreground, - ), - verticalPadding: 4, - onTap: null, + return ConnectionDatabasesFolder( + connection: connection, + databaseCount: databases.length, + onRefresh: onRefreshDatabases, + child: lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _RedisDatabaseNode( connection: connection, - onContextRefresh: onRefreshDatabases, - ), - lazyConnectionTreeList( - context: context, - itemCount: databases.length, - itemBuilder: (context, index) { - final db = databases[index]; - return _RedisDatabaseNode( - connection: connection, - index: db.index, - keys: db.keys, - onTap: () => onDatabaseTap?.call(db.index), - ); - }, - ), - ], + index: db.index, + keys: db.keys, + onTap: () => onDatabaseTap?.call(db.index), + ); + }, ), ); } diff --git a/test/features/connections/connection_databases_folder_test.dart b/test/features/connections/connection_databases_folder_test.dart new file mode 100644 index 00000000..6d5f55f7 --- /dev/null +++ b/test/features/connections/connection_databases_folder_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_databases_folder.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + final conn = ConnectionRow( + type: 'postgresql', + name: 'pg', + host: '127.0.0.1', + port: 5432, + createdAt: DateTime.utc(2025).toIso8601String(), + id: 1, + ); + + testWidgets('ConnectionDatabasesFolder collapses and expands children', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: ConnectionDatabasesFolder( + connection: conn, + databaseCount: 2, + child: const material.Column( + children: [ + material.Text('db_alpha'), + material.Text('db_beta'), + ], + ), + ), + ), + ), + ); + + expect(find.text('Databases (2)'), findsOneWidget); + expect(find.text('db_alpha'), findsOneWidget); + expect(find.text('db_beta'), findsOneWidget); + + await tester.tap(find.text('Databases (2)')); + await tester.pumpAndSettle(); + + expect(find.text('db_alpha'), findsNothing); + expect(find.text('db_beta'), findsNothing); + + await tester.tap(find.text('Databases (2)')); + await tester.pumpAndSettle(); + + expect(find.text('db_alpha'), findsOneWidget); + expect(find.text('db_beta'), findsOneWidget); + }); + + testWidgets( + 'ConnectionDatabasesFolder keeps the same chrome for redis-style use', + (tester) async { + final redis = ConnectionRow( + type: 'redis', + name: 'redis', + host: '127.0.0.1', + port: 6379, + createdAt: DateTime.utc(2025).toIso8601String(), + id: 2, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: ConnectionDatabasesFolder( + connection: redis, + databaseCount: 16, + child: const Text('db0'), + ), + ), + ), + ); + + expect(find.text('Databases (16)'), findsOneWidget); + expect(find.text('db0'), findsOneWidget); + expect(find.byType(QueryaAnimatedExpand), findsOneWidget); + }); +}