From e08e90e1bd1bbe06f4ec72bb806df6e78bb61fee Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 11:07:31 +0300 Subject: [PATCH 01/44] fix(macos): enable bitsdojo custom frame and traffic-light inset Subclass BitsdojoWindow with BDW_CUSTOM_FRAME | BDW_HIDE_ON_STARTUP so macOS matches Win/Linux (no dual native + Flutter titlebars). Reserve leading inset in QueryaWindowTitleBar for traffic lights. Closes #473 --- .../main_screen/querya_window_title_bar.dart | 18 +++++++++++++++++- macos/Runner/MainFlutterWindow.swift | 7 ++++++- .../querya_window_title_bar_test.dart | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 64f68860..f611ff27 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale.dart'; @@ -57,6 +59,15 @@ class QueryaWindowTitleBar extends StatelessWidget { ); } + @visibleForTesting + static double titleBarLeadingInset({ + required bool isMacOS, + required double Function(double designPx) scale, + }) { + // macOS traffic lights sit in the transparent titlebar (bitsdojo custom frame). + return scale(isMacOS ? 72 : 16); + } + @visibleForTesting static WindowButtonColors closeButtonColors(BuildContext context) { final wb = context.workbench; @@ -85,7 +96,12 @@ class QueryaWindowTitleBar extends StatelessWidget { child: MoveWindow( child: Row( children: [ - const SizedBox(width: 16), + SizedBox( + width: QueryaWindowTitleBar.titleBarLeadingInset( + isMacOS: Platform.isMacOS, + scale: context.scaled, + ), + ), material.Icon( material.Icons.storage_rounded, size: 18, diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3cc05eb2..b8e380c0 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -1,7 +1,12 @@ import Cocoa import FlutterMacOS +import bitsdojo_window_macos + +class MainFlutterWindow: BitsdojoWindow { + override func bitsdojo_window_configure() -> UInt { + return BDW_CUSTOM_FRAME | BDW_HIDE_ON_STARTUP + } -class MainFlutterWindow: NSWindow { override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame diff --git a/test/features/main_screen/querya_window_title_bar_test.dart b/test/features/main_screen/querya_window_title_bar_test.dart index 8f1f2674..fbe77a87 100644 --- a/test/features/main_screen/querya_window_title_bar_test.dart +++ b/test/features/main_screen/querya_window_title_bar_test.dart @@ -97,6 +97,24 @@ void main() { expect(background, _customSurface); }); + testWidgets('title bar leading inset reserves macOS traffic-light space', + (tester) async { + expect( + QueryaWindowTitleBar.titleBarLeadingInset( + isMacOS: true, + scale: (v) => v, + ), + 72, + ); + expect( + QueryaWindowTitleBar.titleBarLeadingInset( + isMacOS: false, + scale: (v) => v, + ), + 16, + ); + }); + testWidgets('read-only state is persistently visible in title bar', (tester) async { await tester.pumpWidget( From cd59a33f8d5a9d6edb150cccdcaca1cceb03b739 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 11:16:58 +0300 Subject: [PATCH 02/44] ui(icons): unify mini-icon registry for trees and connection chrome Centralize connection-type icons, tree entity glyphs, size tokens, and SDUI mapping; deduplicate maps across panels and align PG leaf rows with MySQL. Closes #472 --- lib/core/sdui/sdui_tree_builder.dart | 37 +---- lib/core/ui/querya_icon_sizes.dart | 23 +++ lib/core/ui/querya_icons.dart | 90 ++++++++++++ .../connections/connections_panel.dart | 55 +++---- .../connections_panel_extension.dart | 21 +-- .../connections/connections_panel_mongo.dart | 57 ++------ .../connections/connections_panel_mysql.dart | 78 ++++------ .../connections_panel_pg_tree.dart | 135 +++++++----------- ...connections_panel_postgres_connection.dart | 17 +-- .../connections/connections_panel_redis.dart | 2 +- .../connections_panel_sidebar.dart | 2 +- .../connections/connections_panel_sqlite.dart | 30 ++-- .../connections/new_connection_dialog.dart | 17 +-- .../main_screen/workspace_empty_hero.dart | 26 +--- lib/shared/widgets/tree_load_error.dart | 86 +++++++++++ lib/shared/widgets/widgets.dart | 1 + test/core/ui/querya_icons_test.dart | 74 ++++++++++ test/shared/widgets/tree_load_error_test.dart | 43 ++++++ 18 files changed, 463 insertions(+), 331 deletions(-) create mode 100644 lib/core/ui/querya_icon_sizes.dart create mode 100644 lib/core/ui/querya_icons.dart create mode 100644 lib/shared/widgets/tree_load_error.dart create mode 100644 test/core/ui/querya_icons_test.dart create mode 100644 test/shared/widgets/tree_load_error_test.dart diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index 70db9a32..69d06c04 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Renders a sidebar-style tree from an SDUI schema with lazy expansion. @@ -224,8 +226,11 @@ class SduiTreeBuilderState extends material.State { ) else material.Icon( - _iconFor(node), - size: 16, + QueryaIcons.sduiNodeIcon( + node.icon, + expandable: node.expandable, + ), + size: QueryaIconSizes.sduiNode, ), const Gap(8), material.Expanded( @@ -252,32 +257,4 @@ class SduiTreeBuilderState extends material.State { final parts = node.id.split('.'); return parts.isNotEmpty ? parts.first : ''; } - - material.IconData _iconFor(SduiTreeNode node) { - switch (node.icon) { - case 'database': - return material.Icons.storage_outlined; - case 'table': - return material.Icons.table_chart_outlined; - case 'view': - case 'eye': - return material.Icons.visibility_outlined; - case 'folder': - case 'folder-table': - return material.Icons.folder_outlined; - case 'folder-eye': - return material.Icons.folder_special_outlined; - case 'folder-book': - case 'book': - return material.Icons.menu_book_outlined; - case 'columns': - return material.Icons.view_column_outlined; - case 'archive': - return material.Icons.inventory_2_outlined; - default: - return node.expandable - ? material.Icons.folder_outlined - : material.Icons.insert_drive_file_outlined; - } - } } diff --git a/lib/core/ui/querya_icon_sizes.dart b/lib/core/ui/querya_icon_sizes.dart new file mode 100644 index 00000000..6a9e48dc --- /dev/null +++ b/lib/core/ui/querya_icon_sizes.dart @@ -0,0 +1,23 @@ +/// Semantic icon sizes for connection trees and shared chrome. +abstract final class QueryaIconSizes { + /// Leaf row icon (table name, view name, …). + static const double treeLeaf = 12; + + /// Group / schema / default tree row icon. + static const double treeGroup = 13; + + /// Expand chevron in tree rows. + static const double treeExpand = 13; + + /// Database / connection-level tree nodes. + static const double treeConnection = 14; + + /// Inline tree error indicator. + static const double treeError = 14; + + /// SDUI explorer tree nodes. + static const double sduiNode = 16; + + /// Menu / dialog leading icons. + static const double menuLeading = 18; +} diff --git a/lib/core/ui/querya_icons.dart b/lib/core/ui/querya_icons.dart new file mode 100644 index 00000000..f1d4daf7 --- /dev/null +++ b/lib/core/ui/querya_icons.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart' as material; + +/// Shared Material icon registry for connection trees and chrome. +abstract final class QueryaIcons { + // -- Tree entity icons (rounded, aligned across PG / MySQL / SQLite) -- + + static const material.IconData expandClosed = + material.Icons.chevron_right_rounded; + + static const material.IconData databasesFolder = + material.Icons.dns_rounded; + static const material.IconData database = material.Icons.storage_rounded; + static const material.IconData schemasFolder = + material.Icons.account_tree_rounded; + static const material.IconData schema = material.Icons.diamond_rounded; + static const material.IconData extension = material.Icons.extension_rounded; + static const material.IconData publicSchema = material.Icons.public_rounded; + + static const material.IconData tableGroup = + material.Icons.table_chart_rounded; + static const material.IconData tableLeaf = material.Icons.grid_on_rounded; + static const material.IconData viewGroup = material.Icons.view_agenda_rounded; + static const material.IconData viewLeaf = material.Icons.view_week_rounded; + static const material.IconData materializedViewGroup = + material.Icons.dynamic_feed_rounded; + static const material.IconData functionGroup = + material.Icons.functions_rounded; + static const material.IconData functionLeaf = material.Icons.code_rounded; + static const material.IconData sequence = + material.Icons.format_list_numbered_rounded; + static const material.IconData indexes = material.Icons.table_rows_rounded; + static const material.IconData triggers = material.Icons.bolt_rounded; + static const material.IconData types = material.Icons.category_rounded; + + static const material.IconData treeError = + material.Icons.error_outline_rounded; + static const material.IconData folder = material.Icons.folder_rounded; + + // -- Built-in connection types -- + + static material.IconData connectionIcon(String type) => switch (type) { + 'mongodb' => material.Icons.eco_rounded, + 'postgresql' => material.Icons.storage_rounded, + 'mysql' => material.Icons.table_chart_rounded, + 'redis' => material.Icons.memory_rounded, + 'sqlite' => material.Icons.folder_open_rounded, + _ => material.Icons.extension_rounded, + }; + + static String? connectionAsset(String type) => switch (type) { + 'postgresql' => 'assets/images/postgresql_icon.png', + 'mysql' => 'assets/images/mysql_icon.png', + 'redis' => 'assets/images/redis_icon.png', + 'mongodb' => 'assets/images/mongodb_icon.png', + _ => null, + }; + + // -- SDUI tree nodes (rounded to match native trees) -- + + static material.IconData sduiNodeIcon( + String? icon, { + required bool expandable, + }) { + switch (icon) { + case 'database': + return database; + case 'table': + return tableGroup; + case 'view': + case 'eye': + return viewGroup; + case 'folder': + case 'folder-table': + return folder; + case 'folder-eye': + return material.Icons.folder_special_rounded; + case 'folder-book': + case 'book': + return material.Icons.menu_book_rounded; + case 'columns': + return material.Icons.view_column_rounded; + case 'archive': + return material.Icons.inventory_2_rounded; + default: + return expandable + ? folder + : material.Icons.insert_drive_file_rounded; + } + } +} diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index d7f6d105..4100bc66 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -67,6 +67,8 @@ import 'package:querya_desktop/core/sdui/sdui_tree_schema.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/querya_typography.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/motion/querya_animated_expand.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; @@ -395,29 +397,6 @@ class ConnectionsPanelState extends State { } } - /// Icon for a connection type (matches New Connection dialog). - material.IconData _iconForType(String type) { - return switch (type) { - 'mongodb' => material.Icons.eco_rounded, - 'postgresql' => material.Icons.storage_rounded, - 'mysql' => material.Icons.table_chart_rounded, - 'redis' => material.Icons.memory_rounded, - 'sqlite' => material.Icons.folder_open_rounded, - _ => material.Icons.extension_rounded, - }; - } - - /// Asset path for connection type logo (null = use icon). - static String? _iconAssetForType(String type) { - return switch (type) { - 'postgresql' => 'assets/images/postgresql_icon.png', - 'mysql' => 'assets/images/mysql_icon.png', - 'redis' => 'assets/images/redis_icon.png', - 'mongodb' => 'assets/images/mongodb_icon.png', - _ => null, - }; - } - Widget _buildConnectionTile(ConnectionRow conn) { final isSelected = widget.selectedConnectionId != null && widget.selectedConnectionId == conn.id; @@ -436,8 +415,8 @@ class ConnectionsPanelState extends State { return _PostgresConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onPostgresObjectSelected: widget.onPostgresObjectSelected, @@ -449,8 +428,8 @@ class ConnectionsPanelState extends State { return _MysqlConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onMysqlObjectSelected: widget.onMysqlObjectSelected, @@ -462,8 +441,8 @@ class ConnectionsPanelState extends State { return _RedisConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), @@ -474,8 +453,8 @@ class ConnectionsPanelState extends State { return _MongoConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), @@ -486,8 +465,8 @@ class ConnectionsPanelState extends State { return _SqliteConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onSqliteObjectSelected: widget.onSqliteObjectSelected, @@ -499,8 +478,8 @@ class ConnectionsPanelState extends State { return _ExtensionConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onObjectSelected: widget.onExtensionObjectSelected, @@ -511,8 +490,8 @@ class ConnectionsPanelState extends State { return _ConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), ); @@ -602,7 +581,7 @@ class ConnectionsPanelState extends State { .getFolderIdByName(folderName); await _createConnection(folderId: folderId); }, - iconForType: _iconForType, + iconForType: QueryaIcons.connectionIcon, onRemoveConnection: _removeConnection, onConnectionTap: widget.onConnectionSelected, onRedisDatabaseTap: diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 6d343cf9..19036f2e 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -287,29 +287,14 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { ), ) else if (_error != null) - material.Padding( + TreeLoadError( + message: _error!, padding: const material.EdgeInsets.only( left: 28, top: 4, bottom: 8, ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, - ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadTree, - child: const Text('Retry'), - ), - ], - ), + onRetry: _loadTree, ) else if (_schema != null) material.Padding( diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index cc1c5d41..a34e064a 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -281,51 +281,18 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + title: 'Could not load databases', + message: _error!, + showTitleRow: true, + detailFontSize: 10, 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, - ), - ), - ], - ), + left: 28, + top: 4, + bottom: 4, + right: 8, ), + onRetry: _loadDatabases, ), for (final db in _databases) _MongoDatabaseNode( @@ -370,8 +337,8 @@ class _MongoDatabaseNode extends StatelessWidget { padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), child: _PgTreeRow( label: name, - icon: material.Icons.storage_rounded, - iconSize: 13, + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 3d801c6f..98fe6e5c 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -165,7 +165,7 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, + QueryaIcons.expandClosed, size: 16, color: theme.colorScheme.mutedForeground, ), @@ -244,16 +244,14 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + message: _error!, 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), + left: 28, + top: 4, + bottom: 4, ), + onRetry: _loadDatabases, ), if (_databases.isNotEmpty) _MysqlDatabasesNode( @@ -307,8 +305,8 @@ class _MysqlDatabasesNode extends material.StatelessWidget { children: [ _PgTreeRow( label: 'Databases (${databases.length})', - icon: material.Icons.dns_rounded, - iconSize: 14, + icon: QueryaIcons.databasesFolder, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, @@ -445,13 +443,13 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.storage_rounded, - iconSize: 14, + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, @@ -490,29 +488,9 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { ), ) else if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, - top: 4, - bottom: 8, - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, - ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadTables, - child: const Text('Retry'), - ), - ], - ), + TreeLoadError( + message: _error!, + onRetry: _loadTables, ), if (_tables.isNotEmpty || _views.isNotEmpty || @@ -530,8 +508,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.table, onRefresh: _loadTables, label: 'Tables', - icon: material.Icons.table_chart_rounded, - itemIcon: material.Icons.grid_on_rounded, + icon: QueryaIcons.tableGroup, + itemIcon: QueryaIcons.tableLeaf, items: _tables, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -549,8 +527,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.view, onRefresh: _loadTables, label: 'Views', - icon: material.Icons.view_agenda_rounded, - itemIcon: material.Icons.view_week_rounded, + icon: QueryaIcons.viewGroup, + itemIcon: QueryaIcons.viewLeaf, items: _views, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -568,8 +546,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.procedure, onRefresh: _loadTables, label: 'Procedures', - icon: material.Icons.functions_rounded, - itemIcon: material.Icons.code_rounded, + icon: QueryaIcons.functionGroup, + itemIcon: QueryaIcons.functionLeaf, items: _procedures, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -587,8 +565,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.function, onRefresh: _loadTables, label: 'Functions', - icon: material.Icons.functions_rounded, - itemIcon: material.Icons.code_rounded, + icon: QueryaIcons.functionGroup, + itemIcon: QueryaIcons.functionLeaf, items: _functions, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -657,13 +635,13 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), icon: widget.icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, @@ -688,7 +666,7 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { ), label: item, icon: widget.itemIcon, - iconSize: 12, + iconSize: QueryaIconSizes.treeLeaf, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 51ead1e5..a24d20d6 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -61,7 +61,7 @@ class _PgTreeRow extends material.StatelessWidget { required this.label, this.leading, this.icon, - this.iconSize = 13, + this.iconSize = QueryaIconSizes.treeGroup, this.iconColor, this.trailing, this.onTap, @@ -216,13 +216,13 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.dns_rounded, - iconSize: 14, + icon: QueryaIcons.databasesFolder, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, @@ -343,13 +343,13 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.storage_rounded, - iconSize: 14, + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, @@ -371,7 +371,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { connection: widget.connection, databaseName: widget.databaseName, label: 'Extensions', - icon: material.Icons.extension_rounded, + icon: QueryaIcons.extension, kind: PostgresObjectKind.databaseExtensions, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, @@ -381,7 +381,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { connection: widget.connection, databaseName: widget.databaseName, label: 'Foreign data', - icon: material.Icons.public_rounded, + icon: QueryaIcons.publicSchema, kind: PostgresObjectKind.databaseForeignData, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, @@ -405,29 +405,9 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { ), ) else if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, - top: 4, - bottom: 8, - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, - ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadSchemas, - child: const Text('Retry'), - ), - ], - ), + TreeLoadError( + message: _error!, + onRetry: _loadSchemas, ), if (_schemas.isNotEmpty) _PgSchemasNode( @@ -484,11 +464,11 @@ class _PgDbToolRow extends material.StatelessWidget { child: _PgTreeRow( label: label, icon: icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: muted, trailing: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: muted, ), onTap: onPostgresObjectSelected == null @@ -558,13 +538,13 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.account_tree_rounded, - iconSize: 13, + icon: QueryaIcons.schemasFolder, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, @@ -714,13 +694,13 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.diamond_outlined, - iconSize: 13, + icon: QueryaIcons.schema, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.primary.withValues(alpha: 0.6), textStyle: material.TextStyle( fontSize: 12, @@ -755,29 +735,9 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { ), ) else if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, - top: 4, - bottom: 8, - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, - ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadObjects, - child: const Text('Retry'), - ), - ], - ), + TreeLoadError( + message: _error!, + onRetry: _loadObjects, ), if (_loaded && _error == null) ...[ _PgObjectGroup( @@ -789,7 +749,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Tables', - icon: material.Icons.table_chart_rounded, + icon: QueryaIcons.tableGroup, + itemIcon: QueryaIcons.tableLeaf, items: _tables, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -810,7 +771,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Views', - icon: material.Icons.view_agenda_rounded, + icon: QueryaIcons.viewGroup, + itemIcon: QueryaIcons.viewLeaf, items: _views, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -831,7 +793,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Materialized views', - icon: material.Icons.dynamic_feed_rounded, + icon: QueryaIcons.materializedViewGroup, + itemIcon: QueryaIcons.materializedViewGroup, items: _matviews, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -852,7 +815,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Functions', - icon: material.Icons.functions_rounded, + icon: QueryaIcons.functionGroup, + itemIcon: QueryaIcons.functionLeaf, items: _functions, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -873,7 +837,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Sequences', - icon: material.Icons.format_list_numbered_rounded, + icon: QueryaIcons.sequence, + itemIcon: QueryaIcons.sequence, items: _sequences, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -890,7 +855,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { databaseName: widget.databaseName, schemaName: widget.schemaName, label: 'Indexes', - icon: material.Icons.table_rows_rounded, + icon: QueryaIcons.indexes, kind: PostgresObjectKind.schemaIndexes, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: @@ -902,7 +867,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { databaseName: widget.databaseName, schemaName: widget.schemaName, label: 'Triggers', - icon: material.Icons.bolt_rounded, + icon: QueryaIcons.triggers, kind: PostgresObjectKind.schemaTriggers, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: @@ -914,7 +879,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { databaseName: widget.databaseName, schemaName: widget.schemaName, label: 'Types', - icon: material.Icons.category_rounded, + icon: QueryaIcons.types, kind: PostgresObjectKind.schemaTypes, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: @@ -969,11 +934,11 @@ class _PgSchemaToolRow extends material.StatelessWidget { child: _PgTreeRow( label: label, icon: icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: muted, trailing: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: muted, ), onTap: onPostgresObjectSelected == null @@ -1006,6 +971,7 @@ class _PgObjectGroup extends StatefulWidget { required this.onRefresh, required this.label, required this.icon, + required this.itemIcon, required this.items, this.onPostgresOpenSqlWorkspace, this.onItemTap, @@ -1018,6 +984,7 @@ class _PgObjectGroup extends StatefulWidget { final VoidCallback onRefresh; final String label; final material.IconData icon; + final material.IconData itemIcon; final List items; final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; final void Function(String itemName)? onItemTap; @@ -1045,13 +1012,13 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), icon: widget.icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, @@ -1076,8 +1043,8 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { 'pg-${widget.objectKind.name}-${widget.databaseName}-${widget.schemaName}-$item', ), label: item, - icon: widget.icon, - iconSize: 12, + icon: widget.itemIcon, + iconSize: QueryaIconSizes.treeLeaf, iconColor: theme.colorScheme.primary.withValues(alpha: 0.5), textStyle: material.TextStyle( fontSize: 11, diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 5021b92a..5bccba40 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -239,19 +239,14 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + message: _error!, padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Tooltip( - message: _error!, - child: material.Text( - _error!, - overflow: material.TextOverflow.ellipsis, - maxLines: 2, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), - ), + left: 28, + top: 4, + bottom: 4, ), + onRetry: _loadDatabases, ), if (_databases.isNotEmpty) _PgDatabasesNode( diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index cc6d687c..c23254e6 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -312,7 +312,7 @@ class _RedisDatabaseNode extends StatelessWidget { child: material.Row( children: [ material.Icon( - material.Icons.dns_rounded, + QueryaIcons.databasesFolder, size: 14, color: keys > 0 ? theme.colorScheme.primary.withValues(alpha: 0.7) diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index d054ee57..8a540a91 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -296,7 +296,7 @@ class _FolderTileState extends State<_FolderTile> { key: material.ValueKey('folder-conn-${conn.id}'), connection: conn, icon: widget.iconForType(conn.type), - iconAsset: ConnectionsPanelState._iconAssetForType( + iconAsset: QueryaIcons.connectionAsset( conn.type, ), onRemove: () => widget.onRemoveConnection(conn.id!), diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index b403fb6b..2efdee59 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -172,7 +172,7 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, + QueryaIcons.expandClosed, size: 16, color: theme.colorScheme.mutedForeground, ), @@ -251,16 +251,14 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + message: _error!, padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error loading schema', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), + left: 28, + top: 4, + bottom: 4, ), + onRetry: _loadTables, ), if (!_loading && _error == null) material.Padding( @@ -274,8 +272,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { objectKind: SqliteObjectKind.table, onRefresh: _loadTables, label: 'Tables', - icon: material.Icons.table_chart_rounded, - itemIcon: material.Icons.grid_on_rounded, + icon: QueryaIcons.tableGroup, + itemIcon: QueryaIcons.tableLeaf, items: _tables, onItemTap: widget.onSqliteObjectSelected == null ? null @@ -291,8 +289,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { objectKind: SqliteObjectKind.view, onRefresh: _loadTables, label: 'Views', - icon: material.Icons.view_agenda_rounded, - itemIcon: material.Icons.view_week_rounded, + icon: QueryaIcons.viewGroup, + itemIcon: QueryaIcons.viewLeaf, items: _views, onItemTap: widget.onSqliteObjectSelected == null ? null @@ -367,13 +365,13 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, + QueryaIcons.expandClosed, size: 13, color: theme.colorScheme.mutedForeground, ), ), icon: widget.icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, @@ -398,7 +396,7 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { ), label: item, icon: widget.itemIcon, - iconSize: 12, + iconSize: QueryaIconSizes.treeLeaf, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index c4876761..25995f45 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; @@ -27,22 +28,10 @@ extension ConnectionTypeX on ConnectionType { ConnectionType.mongodb => 'MongoDB', ConnectionType.sqlite => 'SQLite', }; - material.IconData get icon => switch (this) { - ConnectionType.postgresql => material.Icons.storage_rounded, - ConnectionType.mysql => material.Icons.table_chart_rounded, - ConnectionType.redis => material.Icons.memory_rounded, - ConnectionType.mongodb => material.Icons.eco_rounded, - ConnectionType.sqlite => material.Icons.folder_open_rounded, - }; + material.IconData get icon => QueryaIcons.connectionIcon(name); /// Asset path for custom icon (from Downloads). - String? get iconAsset => switch (this) { - ConnectionType.postgresql => 'assets/images/postgresql_icon.png', - ConnectionType.mysql => 'assets/images/mysql_icon.png', - ConnectionType.redis => 'assets/images/redis_icon.png', - ConnectionType.mongodb => 'assets/images/mongodb_icon.png', - ConnectionType.sqlite => null, - }; + String? get iconAsset => QueryaIcons.connectionAsset(name); bool get isSql => this == ConnectionType.postgresql || this == ConnectionType.mysql || diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index d0ee2a09..4555d853 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/motion/querya_stagger.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -361,8 +362,8 @@ class _RecentConnectionRow extends StatelessWidget { children: [ DriverIcon( size: 20, - fallbackIcon: _iconForType(connection.type), - assetPath: _iconAssetForType(connection.type), + fallbackIcon: QueryaIcons.connectionIcon(connection.type), + assetPath: QueryaIcons.connectionAsset(connection.type), ), const material.SizedBox(width: 12), material.Expanded( @@ -459,27 +460,6 @@ class _QuickStartRow extends StatelessWidget { } } -material.IconData _iconForType(String type) { - return switch (type) { - 'mongodb' => material.Icons.eco_rounded, - 'postgresql' => material.Icons.storage_rounded, - 'mysql' => material.Icons.table_chart_rounded, - 'redis' => material.Icons.memory_rounded, - 'sqlite' => material.Icons.folder_open_rounded, - _ => material.Icons.extension_rounded, - }; -} - -String? _iconAssetForType(String type) { - return switch (type) { - 'postgresql' => 'assets/images/postgresql_icon.png', - 'mysql' => 'assets/images/mysql_icon.png', - 'redis' => 'assets/images/redis_icon.png', - 'mongodb' => 'assets/images/mongodb_icon.png', - _ => null, - }; -} - String _connectionSubtitle(ConnectionRow connection) { if (connection.type == 'sqlite') { final path = connection.databaseName ?? connection.connectionString; diff --git a/lib/shared/widgets/tree_load_error.dart b/lib/shared/widgets/tree_load_error.dart new file mode 100644 index 00000000..7bc6afdb --- /dev/null +++ b/lib/shared/widgets/tree_load_error.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Inline error block for connection tree lazy-load failures. +class TreeLoadError extends material.StatelessWidget { + const TreeLoadError({ + super.key, + this.title, + required this.message, + this.onRetry, + this.retryLabel = 'Retry', + this.padding = const material.EdgeInsets.only( + left: 24, + top: 4, + bottom: 8, + ), + this.detailFontSize = 11, + this.showTitleRow = false, + }); + + final String? title; + final String message; + final VoidCallback? onRetry; + final String retryLabel; + final material.EdgeInsetsGeometry padding; + final double detailFontSize; + final bool showTitleRow; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final destructive = theme.colorScheme.destructive; + final muted = theme.colorScheme.mutedForeground; + + return material.Padding( + padding: padding, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + if (showTitleRow && title != null) + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Icon( + QueryaIcons.treeError, + size: QueryaIconSizes.treeError, + color: destructive, + ), + const Gap(6), + material.Expanded( + child: material.Text( + title!, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + color: destructive, + ), + ), + ), + ], + ), + if (showTitleRow && title != null) const Gap(6), + material.SelectableText( + message, + style: material.TextStyle( + fontSize: detailFontSize, + height: showTitleRow ? 1.35 : null, + color: showTitleRow ? muted : destructive, + ), + ), + if (onRetry != null) ...[ + const material.SizedBox(height: 6), + GhostButton( + onPressed: onRetry, + child: Text(retryLabel), + ), + ], + ], + ), + ); + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 3fcb27e4..4c9f5bbe 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -18,4 +18,5 @@ export 'querya_dropdown.dart' QueryaDropdownItem, QueryaDropdownTokens, kPreferencesLabelWidth; +export 'tree_load_error.dart'; export 'package:shadcn_flutter/shadcn_flutter.dart'; diff --git a/test/core/ui/querya_icons_test.dart b/test/core/ui/querya_icons_test.dart new file mode 100644 index 00000000..bdeb3b58 --- /dev/null +++ b/test/core/ui/querya_icons_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; + +void main() { + group('QueryaIcons.connectionIcon', () { + test('maps built-in connection types', () { + expect( + QueryaIcons.connectionIcon('postgresql'), + material.Icons.storage_rounded, + ); + expect( + QueryaIcons.connectionIcon('mysql'), + material.Icons.table_chart_rounded, + ); + expect( + QueryaIcons.connectionIcon('redis'), + material.Icons.memory_rounded, + ); + expect( + QueryaIcons.connectionIcon('mongodb'), + material.Icons.eco_rounded, + ); + expect( + QueryaIcons.connectionIcon('sqlite'), + material.Icons.folder_open_rounded, + ); + }); + + test('falls back to extension icon for unknown types', () { + expect( + QueryaIcons.connectionIcon('clickhouse'), + material.Icons.extension_rounded, + ); + }); + }); + + group('QueryaIcons.connectionAsset', () { + test('returns bundled logos for known SQL/NoSQL drivers', () { + expect( + QueryaIcons.connectionAsset('postgresql'), + 'assets/images/postgresql_icon.png', + ); + expect(QueryaIcons.connectionAsset('sqlite'), isNull); + }); + }); + + group('QueryaIcons.sduiNodeIcon', () { + test('uses rounded tree icons for SDUI nodes', () { + expect( + QueryaIcons.sduiNodeIcon('database', expandable: false), + QueryaIcons.database, + ); + expect( + QueryaIcons.sduiNodeIcon('table', expandable: false), + QueryaIcons.tableGroup, + ); + expect( + QueryaIcons.sduiNodeIcon(null, expandable: true), + QueryaIcons.folder, + ); + expect( + QueryaIcons.sduiNodeIcon(null, expandable: false), + material.Icons.insert_drive_file_rounded, + ); + }); + }); + + test('tree size tokens are ordered leaf < group < sdui', () { + expect(QueryaIconSizes.treeLeaf, lessThan(QueryaIconSizes.treeGroup)); + expect(QueryaIconSizes.treeGroup, lessThan(QueryaIconSizes.sduiNode)); + }); +} diff --git a/test/shared/widgets/tree_load_error_test.dart b/test/shared/widgets/tree_load_error_test.dart new file mode 100644 index 00000000..3aa281f8 --- /dev/null +++ b/test/shared/widgets/tree_load_error_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/tree_load_error.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + testWidgets('TreeLoadError shows message and retry', (tester) async { + var retried = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: TreeLoadError( + message: 'connection refused', + onRetry: () => retried = true, + ), + ), + ); + + expect(find.text('connection refused'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + + await tester.tap(find.text('Retry')); + await tester.pump(); + + expect(retried, isTrue); + }); + + testWidgets('TreeLoadError title row uses error icon', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const TreeLoadError( + title: 'Could not load', + message: 'timeout', + showTitleRow: true, + ), + ), + ); + + expect(find.text('Could not load'), findsOneWidget); + expect(find.byIcon(material.Icons.error_outline_rounded), findsOneWidget); + }); +} From 02bf9a1794c8918183ee109fd83e48898194a9f2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 11:21:53 +0300 Subject: [PATCH 03/44] fix: drop unused SelectableText import from connections panel --- lib/features/connections/connections_panel.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 4100bc66..958721a8 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -42,7 +42,6 @@ import 'package:flutter/material.dart' as material Colors, Tooltip, Color, - SelectableText, Padding, Widget, Navigator, From 93b709dc145f625f553927318c809346ef3e92f6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 13:20:01 +0300 Subject: [PATCH 04/44] ui(trees): align Redis sidebar tree with shared row design Use _PgTreeRow for db nodes, add Databases group header, TreeLoadError, and database leaf icons so Redis matches PG/MySQL tree chrome. --- .../connections/connections_panel_redis.dart | 146 +++++++++++------- 1 file changed, 92 insertions(+), 54 deletions(-) diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index c23254e6..2e01abfa 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -258,22 +258,24 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + message: _error!, 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), + left: 28, + top: 4, + bottom: 4, ), + onRetry: _loadDatabases, ), - for (final db in _databases) - _RedisDatabaseNode( - index: db.index, - keys: db.keys, - onTap: () => widget.onDatabaseTap?.call(db.index), + if (_databases.isNotEmpty) + _RedisDatabasesNode( + connection: widget.connection, + databases: _databases, + onRefreshDatabases: () { + setState(() => _databases = []); + _loadDatabases(); + }, + onDatabaseTap: widget.onDatabaseTap, ), ], ), @@ -285,6 +287,60 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { } } +class _RedisDatabasesNode extends material.StatelessWidget { + const _RedisDatabasesNode({ + required this.connection, + required this.databases, + required this.onRefreshDatabases, + this.onDatabaseTap, + }); + + final ConnectionRow connection; + final List<({int index, int keys})> databases; + final VoidCallback onRefreshDatabases; + final void Function(int database)? onDatabaseTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 20), + 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, + connection: connection, + onContextRefresh: onRefreshDatabases, + ), + lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _RedisDatabaseNode( + index: db.index, + keys: db.keys, + onTap: () => onDatabaseTap?.call(db.index), + ); + }, + ), + ], + ), + ); + } +} + class _RedisDatabaseNode extends StatelessWidget { const _RedisDatabaseNode({ required this.index, @@ -300,49 +356,31 @@ class _RedisDatabaseNode extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); return 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( - QueryaIcons.databasesFolder, - size: 14, - color: keys > 0 - ? theme.colorScheme.primary.withValues(alpha: 0.7) - : theme.colorScheme.mutedForeground - .withValues(alpha: 0.5), + padding: const material.EdgeInsets.only(left: 16), + child: _PgTreeRow( + label: 'db$index', + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, + iconColor: keys > 0 + ? theme.colorScheme.primary.withValues(alpha: 0.7) + : theme.colorScheme.mutedForeground.withValues(alpha: 0.5), + trailing: keys > 0 + ? material.Text( + '$keys', + style: material.TextStyle( + fontSize: 10, + color: theme.colorScheme.mutedForeground, ), - const Gap(8), - material.Expanded( - child: material.Text( - 'db$index', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 12, - color: keys > 0 - ? theme.colorScheme.foreground - : theme.colorScheme.mutedForeground, - ), - ), - ), - if (keys > 0) - material.Text( - '$keys', - style: material.TextStyle( - fontSize: 10, color: theme.colorScheme.mutedForeground), - ), - ], - ), - ), + ) + : null, + textStyle: material.TextStyle( + fontSize: 12, + color: keys > 0 + ? theme.colorScheme.foreground + : theme.colorScheme.mutedForeground, ), + verticalPadding: 3, + onTap: onTap, ), ); } From 906bcd6803375be86b56a0c0a7585ed033aed6ad Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 13:20:04 +0300 Subject: [PATCH 05/44] ui(trees): unify SDUI/SQLite leaf styling and tree indentation Match extension and SDUI trees to native row tokens (chevron, sizes, group vs leaf icons), and shift terminal entities right for clearer hierarchy across PG, MySQL, SQLite, and extension drivers. --- lib/core/sdui/sdui_tree_builder.dart | 34 ++++++++++++++----- lib/core/ui/querya_icons.dart | 4 +-- .../connections_panel_extension.dart | 2 +- .../connections/connections_panel_mysql.dart | 2 +- .../connections_panel_pg_tree.dart | 2 +- .../connections/connections_panel_sqlite.dart | 4 +-- 6 files changed, 32 insertions(+), 16 deletions(-) diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index 69d06c04..7ab1795c 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -50,7 +50,7 @@ class SduiTreeBuilderState extends material.State { final Set _expanded = {}; final Map _expandErrors = {}; - static const double _rowExtent = 36; + static const double _rowExtent = 28; @override void initState() { @@ -180,17 +180,27 @@ class SduiTreeBuilderState extends material.State { } material.Widget _buildNodeRow(SduiTreeNode node, {required int depth}) { + final theme = material.Theme.of(context); + final muted = theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85); + final primary = theme.colorScheme.primary; final canExpand = node.expandable || node.hasChildren; final isExpanded = _expanded.contains(node.id); final isLoading = _loading.contains(node.id); final nodeKind = _resolveNodeKind(node); final isBrowsable = nodeKind == 'table' || nodeKind == 'view'; + final iconSize = canExpand + ? QueryaIconSizes.treeGroup + : QueryaIconSizes.treeLeaf; + final iconColor = isBrowsable + ? primary.withValues(alpha: 0.5) + : muted; + final rowLeft = 8.0 + depth * 16.0 + (canExpand ? 0 : 4.0); return material.InkWell( onTap: isBrowsable ? () => widget.onNodeSelected?.call(node) : null, child: material.Padding( padding: material.EdgeInsets.only( - left: 8.0 + depth * 16.0, + left: rowLeft, right: 8, ), child: material.Row( @@ -201,7 +211,7 @@ class SduiTreeBuilderState extends material.State { height: 28, child: material.IconButton( padding: material.EdgeInsets.zero, - iconSize: 18, + iconSize: QueryaIconSizes.treeExpand, onPressed: () { if (isExpanded) { _onCollapse(node); @@ -209,10 +219,14 @@ class SduiTreeBuilderState extends material.State { _onExpand(node); } }, - icon: material.Icon( - isExpanded - ? material.Icons.expand_more - : material.Icons.chevron_right, + icon: material.AnimatedRotation( + turns: isExpanded ? 0.25 : 0, + duration: const Duration(milliseconds: 160), + curve: material.Curves.easeOutCubic, + child: const material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, + ), ), ), ) @@ -230,7 +244,8 @@ class SduiTreeBuilderState extends material.State { node.icon, expandable: node.expandable, ), - size: QueryaIconSizes.sduiNode, + size: iconSize, + color: iconColor, ), const Gap(8), material.Expanded( @@ -239,7 +254,8 @@ class SduiTreeBuilderState extends material.State { overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( - fontSize: 12, + fontSize: 11, + color: isBrowsable ? theme.colorScheme.onSurface : muted, fontWeight: isBrowsable ? material.FontWeight.w600 : null, ), ), diff --git a/lib/core/ui/querya_icons.dart b/lib/core/ui/querya_icons.dart index f1d4daf7..297dcf4e 100644 --- a/lib/core/ui/querya_icons.dart +++ b/lib/core/ui/querya_icons.dart @@ -65,10 +65,10 @@ abstract final class QueryaIcons { case 'database': return database; case 'table': - return tableGroup; + return expandable ? tableGroup : tableLeaf; case 'view': case 'eye': - return viewGroup; + return expandable ? viewGroup : viewLeaf; case 'folder': case 'folder-table': return folder; diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 19036f2e..28743426 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -184,7 +184,7 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, + QueryaIcons.expandClosed, size: 16, color: theme.colorScheme.mutedForeground, ), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 98fe6e5c..eaf0e582 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -657,7 +657,7 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { context: context, itemCount: widget.items.length, itemExtent: kConnectionTreeRowExtent, - padding: const material.EdgeInsets.only(left: 22), + padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; return _PgTreeRow( diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index a24d20d6..4281af75 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -1035,7 +1035,7 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { context: context, itemCount: widget.items.length, itemExtent: kConnectionTreeRowExtent, - padding: const material.EdgeInsets.only(left: 22), + padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; return _PgTreeRow( diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 2efdee59..1456068c 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -366,7 +366,7 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( QueryaIcons.expandClosed, - size: 13, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), @@ -387,7 +387,7 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { context: context, itemCount: widget.items.length, itemExtent: kConnectionTreeRowExtent, - padding: const material.EdgeInsets.only(left: 22), + padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; return _PgTreeRow( From 0036c7637a4f6a3325b777f4d45c4dd9d4581891 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 13:25:02 +0300 Subject: [PATCH 06/44] test: update SDUI icon and tree builder expectations Reflect group vs leaf table/view icons and QueryaIcons.expandClosed chevron used by SduiTreeBuilder after tree visual unification. --- test/core/sdui/sdui_builders_test.dart | 3 ++- test/core/ui/querya_icons_test.dart | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart index 3181f68e..dd432534 100644 --- a/test/core/sdui/sdui_builders_test.dart +++ b/test/core/sdui/sdui_builders_test.dart @@ -4,6 +4,7 @@ import 'package:querya_desktop/core/sdui/sdui_form_builder.dart'; import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_builder.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -206,7 +207,7 @@ void main() { expect(find.text('Databases'), findsOneWidget); expect(find.text('analytics'), findsNothing); - await tester.tap(find.byIcon(material.Icons.chevron_right)); + await tester.tap(find.byIcon(QueryaIcons.expandClosed)); await tester.pumpAndSettle(); expect(fetches, 1); diff --git a/test/core/ui/querya_icons_test.dart b/test/core/ui/querya_icons_test.dart index bdeb3b58..7f93df88 100644 --- a/test/core/ui/querya_icons_test.dart +++ b/test/core/ui/querya_icons_test.dart @@ -53,9 +53,21 @@ void main() { QueryaIcons.database, ); expect( - QueryaIcons.sduiNodeIcon('table', expandable: false), + QueryaIcons.sduiNodeIcon('table', expandable: true), QueryaIcons.tableGroup, ); + expect( + QueryaIcons.sduiNodeIcon('table', expandable: false), + QueryaIcons.tableLeaf, + ); + expect( + QueryaIcons.sduiNodeIcon('view', expandable: true), + QueryaIcons.viewGroup, + ); + expect( + QueryaIcons.sduiNodeIcon('view', expandable: false), + QueryaIcons.viewLeaf, + ); expect( QueryaIcons.sduiNodeIcon(null, expandable: true), QueryaIcons.folder, From 0fc0c2e281b426d42e445214e2fcf3b6f4c88bfe Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 14:05:32 +0300 Subject: [PATCH 07/44] =?UTF-8?q?ui(motion):=20morph=20home=E2=86=94object?= =?UTF-8?q?=20workspace=20switches=20(#478)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap driver home/stats and object/explorer views in QueryaSwitchingBody with FadeSlide for object→object changes so sidebar selection no longer hard-cuts. Home stays keep-alive for SQL/editor state. Closes #478 --- lib/features/main_screen/workspace_panel.dart | 224 +++++++++++------- .../workspace_panel_layout_test.dart | 82 ++++++- 2 files changed, 214 insertions(+), 92 deletions(-) diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index b3c40058..bf9f3e92 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -13,6 +13,7 @@ import 'package:flutter/material.dart' as material Widget, Column; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -175,115 +176,141 @@ class _WorkspacePanelState extends State { switch (activeConn.type) { case 'postgresql': final pg = widget.selectedPostgresObject; - driverWorkspace = pg == null - ? PostgresWorkspaceHome( - key: ValueKey('pg_home_${activeConn.id}'), - connectionRow: activeConn, - postgresSqlEditorContext: widget.postgresSqlEditorContext, - postgresSqlEditorContextToken: - widget.postgresSqlEditorContextToken, - sqlTabRequestToken: widget.postgresSqlTabRequestToken, - isReadOnly: widget.isReadOnly, - ) - : buildPostgresObjectWorkspace( - connection: activeConn, - pg: pg, - ); + driverWorkspace = _homeObjectMorph( + showingObject: pg != null, + home: PostgresWorkspaceHome( + key: ValueKey('pg_home_${activeConn.id}'), + connectionRow: activeConn, + postgresSqlEditorContext: widget.postgresSqlEditorContext, + postgresSqlEditorContextToken: + widget.postgresSqlEditorContextToken, + sqlTabRequestToken: widget.postgresSqlTabRequestToken, + isReadOnly: widget.isReadOnly, + ), + object: pg == null + ? null + : buildPostgresObjectWorkspace( + connection: activeConn, + pg: pg, + ), + ); break; case 'mysql': final my = widget.selectedMysqlObject; - if (my == null) { - driverWorkspace = MysqlWorkspaceHome( + material.Widget? mysqlObject; + if (my != null) { + if (my.kind == MysqlObjectKind.procedure || + my.kind == MysqlObjectKind.function) { + mysqlObject = MysqlRoutineView( + key: ValueKey( + 'mysql_${activeConn.id}_${my.database}_${my.name}_${my.kind}', + ), + connectionRow: activeConn, + database: my.database, + routineName: my.name, + isFunction: my.kind == MysqlObjectKind.function, + ); + } else { + mysqlObject = MysqlTableView( + key: ValueKey( + 'mysql_${activeConn.id}_${my.database}_${my.name}_${my.kind}', + ), + connectionRow: activeConn, + database: my.database, + tableName: my.name, + isView: my.kind == MysqlObjectKind.view, + ); + } + } + driverWorkspace = _homeObjectMorph( + showingObject: my != null, + home: MysqlWorkspaceHome( key: ValueKey('mysql_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.mysqlSqlTabRequestToken, isReadOnly: widget.isReadOnly, - ); - } else if (my.kind == MysqlObjectKind.procedure || - my.kind == MysqlObjectKind.function) { - driverWorkspace = MysqlRoutineView( - key: ValueKey( - 'mysql_${activeConn.id}_${my.database}_${my.name}_${my.kind}', - ), - connectionRow: activeConn, - database: my.database, - routineName: my.name, - isFunction: my.kind == MysqlObjectKind.function, - ); - } else { - driverWorkspace = MysqlTableView( - key: ValueKey( - 'mysql_${activeConn.id}_${my.database}_${my.name}_${my.kind}', - ), - connectionRow: activeConn, - database: my.database, - tableName: my.name, - isView: my.kind == MysqlObjectKind.view, - ); - } + ), + object: mysqlObject, + ); break; case 'mongodb': final mongoDb = widget.selectedMongoDb; - driverWorkspace = mongoDb != null - ? MongoExplorerView( - key: ValueKey('mongo_${activeConn.id}_db_$mongoDb'), - connectionRow: activeConn, - database: mongoDb, - ) - : MongoStatsView( - key: ValueKey(activeConn.id), - connectionRow: activeConn, - ); + driverWorkspace = _homeObjectMorph( + showingObject: mongoDb != null, + home: MongoStatsView( + key: ValueKey('mongo_stats_${activeConn.id}'), + connectionRow: activeConn, + ), + object: mongoDb == null + ? null + : MongoExplorerView( + key: ValueKey('mongo_${activeConn.id}_db_$mongoDb'), + connectionRow: activeConn, + database: mongoDb, + ), + ); break; case 'redis': final redisDb = widget.selectedRedisDb; - driverWorkspace = redisDb != null - ? RedisExplorerView( - key: ValueKey('redis_${activeConn.id}_db_$redisDb'), - connectionRow: activeConn, - database: redisDb, - ) - : RedisView( - key: ValueKey(activeConn.id), - connectionRow: activeConn, - ); + driverWorkspace = _homeObjectMorph( + showingObject: redisDb != null, + home: RedisView( + key: ValueKey('redis_stats_${activeConn.id}'), + connectionRow: activeConn, + ), + object: redisDb == null + ? null + : RedisExplorerView( + key: ValueKey('redis_${activeConn.id}_db_$redisDb'), + connectionRow: activeConn, + database: redisDb, + ), + ); break; case 'sqlite': final sq = widget.selectedSqliteObject; - driverWorkspace = sq == null - ? SqliteWorkspaceHome( - key: ValueKey('sqlite_home_${activeConn.id}'), - connectionRow: activeConn, - sqlTabRequestToken: widget.sqliteSqlTabRequestToken, - isReadOnly: widget.isReadOnly, - ) - : SqliteTableView( - key: ValueKey( - 'sqlite_${activeConn.id}_${sq.name}_${sq.kind}', + driverWorkspace = _homeObjectMorph( + showingObject: sq != null, + home: SqliteWorkspaceHome( + key: ValueKey('sqlite_home_${activeConn.id}'), + connectionRow: activeConn, + sqlTabRequestToken: widget.sqliteSqlTabRequestToken, + isReadOnly: widget.isReadOnly, + ), + object: sq == null + ? null + : SqliteTableView( + key: ValueKey( + 'sqlite_${activeConn.id}_${sq.name}_${sq.kind}', + ), + connectionRow: activeConn, + tableName: sq.name, + isView: sq.kind == SqliteObjectKind.view, ), - connectionRow: activeConn, - tableName: sq.name, - isView: sq.kind == SqliteObjectKind.view, - ); + ); break; default: if (ExtensionDriverCatalog.isExtensionDriverConnection(activeConn)) { final obj = widget.selectedExtensionObject; - driverWorkspace = obj == null - ? ExtensionWorkspaceHome( - key: ValueKey('ext_home_${activeConn.id}'), - connectionRow: activeConn, - sqlTabRequestToken: widget.extensionSqlTabRequestToken, - isReadOnly: widget.isReadOnly, - ) - : ExtensionTableView( - key: ValueKey( - 'ext_table_${activeConn.id}_${obj.database}_${obj.name}', + driverWorkspace = _homeObjectMorph( + showingObject: obj != null, + home: ExtensionWorkspaceHome( + key: ValueKey('ext_home_${activeConn.id}'), + connectionRow: activeConn, + sqlTabRequestToken: widget.extensionSqlTabRequestToken, + isReadOnly: widget.isReadOnly, + ), + object: obj == null + ? null + : ExtensionTableView( + key: ValueKey( + 'ext_table_${activeConn.id}_${obj.database}_${obj.name}', + ), + connectionRow: activeConn, + database: obj.database, + tableName: obj.name, ), - connectionRow: activeConn, - database: obj.database, - tableName: obj.name, - ); + ); } break; } @@ -314,4 +341,27 @@ class _WorkspacePanelState extends State { ), ); } + + /// Home (stats / SQL) stays keep-alive; object/explorer morphs in on top. + /// + /// Object→object switches use [QueryaFadeSlide] so table/DB changes do not + /// hard-cut. Does not animate virtualized result rows inside those views. + material.Widget _homeObjectMorph({ + required bool showingObject, + required material.Widget home, + required material.Widget? object, + }) { + return QueryaSwitchingBody( + index: showingObject ? 1 : 0, + children: [ + home, + QueryaFadeSlide( + child: object ?? + const material.SizedBox.expand( + key: ValueKey('workspace_object_placeholder'), + ), + ), + ], + ); + } } diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index 38e3a405..38111323 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/main_screen/workspace_panel.dart'; +import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; +import 'package:querya_desktop/features/redis/redis_view.dart'; import '../../support/layout_overflow.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -18,6 +21,15 @@ void main() { createdAt: '0', ); + const redisConnection = ConnectionRow( + id: 42, + type: 'redis', + name: 'redis-test', + host: '127.0.0.1', + port: 6379, + createdAt: '0', + ); + group('WorkspacePanel layout (no connection)', () { final sizes = { 'narrow_tall': const material.Size(320, 720), @@ -75,7 +87,8 @@ void main() { ), ); - expect(find.byKey(const material.Key('workspace_run_button')), findsNothing); + expect( + find.byKey(const material.Key('workspace_run_button')), findsNothing); expect(find.text('Execute/Refresh (F5)'), findsNothing); }); @@ -93,7 +106,8 @@ void main() { ), ); - expect(find.byKey(const material.Key('workspace_run_button')), findsNothing); + expect( + find.byKey(const material.Key('workspace_run_button')), findsNothing); expect(find.text('Query History'), findsNothing); expect(find.textContaining('Coming in a future release'), findsNothing); }); @@ -109,7 +123,7 @@ void main() { ), ), ); - expect(find.byType(QueryaSwitchingBody), findsOneWidget); + expect(find.byType(QueryaSwitchingBody), findsWidgets); await pumpWidgetWithSurfaceSize( tester, @@ -123,7 +137,7 @@ void main() { ), ); await tester.pump(); - expect(find.byType(QueryaSwitchingBody), findsOneWidget); + expect(find.byType(QueryaSwitchingBody), findsWidgets); expect(find.text('Unsupported connection type'), findsOneWidget); // Back to empty — keep-alive stack stays mounted. @@ -137,7 +151,65 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.byType(QueryaSwitchingBody), findsOneWidget); + expect(find.byType(QueryaSwitchingBody), findsWidgets); + }); + }); + + group('WorkspacePanel home↔object morph', () { + testWidgets('Redis stats↔explorer uses SwitchingBody + FadeSlide', + (tester) async { + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(activeConnection: redisConnection), + ), + ), + ); + await tester.pump(); + + // Outer empty↔connected + inner home↔object (+ hero FadeSlide keep-alive). + expect(find.byType(QueryaSwitchingBody), findsNWidgets(2)); + expect(find.byType(QueryaFadeSlide), findsWidgets); + expect(find.byType(RedisView), findsOneWidget); + + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel( + activeConnection: redisConnection, + selectedRedisDb: 0, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byType(QueryaSwitchingBody), findsNWidgets(2)); + expect(find.byType(QueryaFadeSlide), findsWidgets); + expect(find.byType(RedisExplorerView), findsOneWidget); + // Home stays keep-alive under SwitchingBody. + expect(find.byType(RedisView), findsOneWidget); + + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(activeConnection: redisConnection), + ), + ), + ); + // Avoid pumpAndSettle — Redis stats polling keeps a ticker alive. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + + expect(find.byType(RedisView), findsOneWidget); + expect(find.byType(QueryaSwitchingBody), findsNWidgets(2)); }); }); } From b93be97ff573ecf8d245245c57692b63eeca1251 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 14:11:33 +0300 Subject: [PATCH 08/44] ui(motion): put SDUI tree chevrons on QueryaMotion tokens Replace magic 160ms/easeOutCubic IconButton rotation with the same fast/standardCurve dialect as native connection trees; use muted expandClosed chevron; respect Motion Off. Height morph stays out of the flat virtualized ListView (documented; coordinated timing in #480). Closes #479 --- lib/core/sdui/sdui_tree_builder.dart | 76 +++++++++++++++----------- test/core/sdui/sdui_builders_test.dart | 41 ++++++++++++++ 2 files changed, 86 insertions(+), 31 deletions(-) diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index 7ab1795c..fc845187 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; import 'package:querya_desktop/core/ui/querya_icons.dart'; @@ -8,6 +10,10 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; /// /// Visible rows are flattened into a [ListView.builder] so only viewport /// rows are built (large schemas no longer create a full widget Column). +/// Expand chevrons use [QueryaMotion] tokens (same dialect as native trees). +/// Height morph via [QueryaAnimatedExpand] is not used here: nested expand +/// widgets conflict with the flat virtualized row list (see issue #480 for +/// coordinated expand timing across trees). class SduiTreeBuilder extends material.StatefulWidget { const SduiTreeBuilder({ super.key, @@ -110,6 +116,14 @@ class SduiTreeBuilderState extends material.State { setState(() => _expanded.remove(node.id)); } + void _toggleExpand(SduiTreeNode node) { + if (_expanded.contains(node.id)) { + _onCollapse(node); + } else { + _onExpand(node); + } + } + List _replaceNode( List nodes, String id, @@ -180,24 +194,28 @@ class SduiTreeBuilderState extends material.State { } material.Widget _buildNodeRow(SduiTreeNode node, {required int depth}) { - final theme = material.Theme.of(context); - final muted = theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85); + final theme = Theme.of(context); + final muted = theme.colorScheme.mutedForeground; final primary = theme.colorScheme.primary; final canExpand = node.expandable || node.hasChildren; final isExpanded = _expanded.contains(node.id); final isLoading = _loading.contains(node.id); final nodeKind = _resolveNodeKind(node); final isBrowsable = nodeKind == 'table' || nodeKind == 'view'; - final iconSize = canExpand - ? QueryaIconSizes.treeGroup - : QueryaIconSizes.treeLeaf; - final iconColor = isBrowsable - ? primary.withValues(alpha: 0.5) - : muted; + final iconSize = + canExpand ? QueryaIconSizes.treeGroup : QueryaIconSizes.treeLeaf; + final iconColor = isBrowsable ? primary.withValues(alpha: 0.5) : muted; final rowLeft = 8.0 + depth * 16.0 + (canExpand ? 0 : 4.0); return material.InkWell( - onTap: isBrowsable ? () => widget.onNodeSelected?.call(node) : null, + onTap: () { + if (isBrowsable) { + widget.onNodeSelected?.call(node); + } else if (canExpand) { + _toggleExpand(node); + } + }, + borderRadius: material.BorderRadius.circular(4), child: material.Padding( padding: material.EdgeInsets.only( left: rowLeft, @@ -206,32 +224,28 @@ class SduiTreeBuilderState extends material.State { child: material.Row( children: [ if (canExpand) - material.SizedBox( - width: 28, - height: 28, - child: material.IconButton( - padding: material.EdgeInsets.zero, - iconSize: QueryaIconSizes.treeExpand, - onPressed: () { - if (isExpanded) { - _onCollapse(node); - } else { - _onExpand(node); - } - }, - icon: material.AnimatedRotation( - turns: isExpanded ? 0.25 : 0, - duration: const Duration(milliseconds: 160), - curve: material.Curves.easeOutCubic, - child: const material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.treeExpand, + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: () => _toggleExpand(node), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: isExpanded ? 0.25 : 0, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, + color: muted, + ), ), ), ), ) else - const material.SizedBox(width: 28), + const material.SizedBox(width: QueryaIconSizes.treeExpand + 4), if (isLoading) const material.SizedBox( width: 14, @@ -255,7 +269,7 @@ class SduiTreeBuilderState extends material.State { maxLines: 1, style: material.TextStyle( fontSize: 11, - color: isBrowsable ? theme.colorScheme.onSurface : muted, + color: isBrowsable ? theme.colorScheme.foreground : muted, fontWeight: isBrowsable ? material.FontWeight.w600 : null, ), ), diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart index dd432534..f37aea2f 100644 --- a/test/core/sdui/sdui_builders_test.dart +++ b/test/core/sdui/sdui_builders_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/core/sdui/sdui_form_builder.dart'; import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_builder.dart'; @@ -214,6 +216,45 @@ void main() { expect(find.text('analytics'), findsOneWidget); }); + testWidgets('expand chevron uses QueryaMotion tokens (Off = instant)', + (tester) async { + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'databases', + 'label': 'Databases', + 'expandable': true, + }, + ], + }); + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.off, + child: material.Scaffold( + body: SduiTreeBuilder( + schema: schema, + fetchChildren: (_) async => const [ + SduiTreeNode(id: 'db1', label: 'analytics'), + ], + ), + ), + ), + ), + ); + + final rotation = tester.widget( + find.byType(material.AnimatedRotation), + ); + expect(rotation.duration, Duration.zero); + + await tester.tap(find.byIcon(QueryaIcons.expandClosed)); + await tester.pumpAndSettle(); + + expect(find.text('analytics'), findsOneWidget); + }); + testWidgets('selects table nodes by id prefix when meta is empty', (tester) async { SduiTreeNode? selected; From caa43bf9fc284309f7ac522de57e3c7ef3ccf7d9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 14:13:20 +0300 Subject: [PATCH 09/44] fix: drop unused querya_motion import in SDUI tests --- test/core/sdui/sdui_builders_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart index f37aea2f..20e00f93 100644 --- a/test/core/sdui/sdui_builders_test.dart +++ b/test/core/sdui/sdui_builders_test.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/core/sdui/sdui_form_builder.dart'; import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; From a14f472f3f7b60ab31c6dbdb90d0f10298568aa3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 14:21:11 +0300 Subject: [PATCH 10/44] ui(motion): unify tree expand chevron and height tokens (#480) Add QueryaMotion.treeExpand / treeExpandCurve and wire both QueryaAnimatedExpand and connection/SDUI tree chevrons to the same pair so one expand gesture no longer finishes on two clocks. Closes #480 --- docs/motion-and-high-refresh.md | 9 +++++++-- lib/core/motion/querya_animated_expand.dart | 4 ++-- lib/core/motion/querya_motion.dart | 9 ++++++++- lib/core/sdui/sdui_tree_builder.dart | 12 +++++------ .../connections_panel_extension.dart | 4 ++-- .../connections/connections_panel_mongo.dart | 4 ++-- .../connections/connections_panel_mysql.dart | 12 +++++------ .../connections_panel_pg_tree.dart | 20 +++++++++---------- ...connections_panel_postgres_connection.dart | 4 ++-- .../connections/connections_panel_redis.dart | 4 ++-- .../connections_panel_sidebar.dart | 4 ++-- .../connections/connections_panel_sqlite.dart | 8 ++++---- .../motion/querya_animated_expand_test.dart | 13 ++++++++++++ test/core/motion/querya_motion_test.dart | 2 ++ 14 files changed, 68 insertions(+), 41 deletions(-) diff --git a/docs/motion-and-high-refresh.md b/docs/motion-and-high-refresh.md index 9fa2d07b..cb77c4d9 100644 --- a/docs/motion-and-high-refresh.md +++ b/docs/motion-and-high-refresh.md @@ -82,7 +82,8 @@ Introduce `lib/core/motion/` with a single source of truth for durations and cur |-------|-------|-----| | `instant` | 0 ms | reduced-motion / disabled | | `fast` | 120 ms | hover, small state changes | -| `standard` | 200 ms | dialogs, menus, expand/collapse | +| `standard` | 200 ms | dialogs, menus, general surfaces | +| `treeExpand` | 200 ms (= `standard`) | connection/SDUI tree: **chevron + height** share one clock (#480) | | `slow` | 320 ms | emphasized / large surfaces, theme cross-fade | ### 4.2 Curve tokens @@ -91,7 +92,8 @@ Introduce `lib/core/motion/` with a single source of truth for durations and cur |-------|-------|-----| | `enter` | `easeOutCubic` | elements appearing (decelerate) | | `exit` | `easeInCubic` | elements leaving (accelerate) | -| `standard` | `easeInOutCubic` | move/resize in place | +| `standardCurve` | `easeInOutCubic` | move/resize in place | +| `treeExpandCurve` | = `enter` | tree expand chevron + `QueryaAnimatedExpand` | | `emphasized` | `Curves.easeInOutCubicEmphasized` | hero / theme transitions | ### 4.3 Reduced motion / accessibility @@ -151,6 +153,9 @@ When reviewing PRs that touch animation: 2. Require Full / Reduced / Off + OS `disableAnimations` coverage for new transitions. 3. Split / resize: no spring or lag mid-drag; settle only on release / focus chrome. 4. Never stagger or fade virtualized result rows while scrolling. +5. Tree expand: chevron `AnimatedRotation` and `QueryaAnimatedExpand` **must** use + `QueryaMotion.treeExpand` + `treeExpandCurve` (not `fast`/`standardCurve` mixed + with `standard`/`enter`). **Allowed named non-token durations** (named + documented — not magic literals at call sites): diff --git a/lib/core/motion/querya_animated_expand.dart b/lib/core/motion/querya_animated_expand.dart index 96dd97c5..62108875 100644 --- a/lib/core/motion/querya_animated_expand.dart +++ b/lib/core/motion/querya_animated_expand.dart @@ -19,8 +19,8 @@ class QueryaAnimatedExpand extends StatelessWidget { @override Widget build(BuildContext context) { return AnimatedSize( - duration: context.motionDuration(QueryaMotion.standard), - curve: context.motionCurve(QueryaMotion.enter), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), alignment: alignment, clipBehavior: Clip.hardEdge, child: diff --git a/lib/core/motion/querya_motion.dart b/lib/core/motion/querya_motion.dart index d7064e3c..14efdd37 100644 --- a/lib/core/motion/querya_motion.dart +++ b/lib/core/motion/querya_motion.dart @@ -14,12 +14,16 @@ abstract final class QueryaMotion { /// Hover, small state changes. static const Duration fast = Duration(milliseconds: 120); - /// Dialogs, menus, expand/collapse. + /// Dialogs, menus, general surface transitions. static const Duration standard = Duration(milliseconds: 200); /// Emphasized transitions (theme cross-fade, large surfaces). static const Duration slow = Duration(milliseconds: 320); + /// Connection / SDUI tree expand: chevron rotation **and** height morph share + /// this duration so one gesture does not finish on two clocks (#480). + static const Duration treeExpand = standard; + /// Elements appearing (decelerate). static const Curve enter = Curves.easeOutCubic; @@ -32,6 +36,9 @@ abstract final class QueryaMotion { /// Hero / theme transitions. static const Curve emphasized = Curves.easeInOutCubicEmphasized; + /// Curve for [treeExpand] (chevron + [QueryaAnimatedExpand] height). + static const Curve treeExpandCurve = enter; + /// Returns [token] adjusted for accessibility and [QueryaMotionScope] level. static Duration effectiveDuration(BuildContext context, Duration token) { if (token == instant) return instant; diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index fc845187..4324062e 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -10,10 +10,10 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; /// /// Visible rows are flattened into a [ListView.builder] so only viewport /// rows are built (large schemas no longer create a full widget Column). -/// Expand chevrons use [QueryaMotion] tokens (same dialect as native trees). -/// Height morph via [QueryaAnimatedExpand] is not used here: nested expand -/// widgets conflict with the flat virtualized row list (see issue #480 for -/// coordinated expand timing across trees). +/// Expand chevrons and height morph share [QueryaMotion.treeExpand] / +/// [QueryaMotion.treeExpandCurve]. Height morph via [QueryaAnimatedExpand] is +/// not used on the flat virtualized row list (nested expand would fight +/// `ListView` itemExtent); chevron timing still matches native trees. class SduiTreeBuilder extends material.StatefulWidget { const SduiTreeBuilder({ super.key, @@ -233,8 +233,8 @@ class SduiTreeBuilderState extends material.State { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: isExpanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 28743426..05077770 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -181,8 +181,8 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: widget.isExpanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: 16, diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index a34e064a..a1193b36 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -197,8 +197,8 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index eaf0e582..069001bd 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -162,8 +162,8 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: 16, @@ -440,8 +440,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, @@ -632,8 +632,8 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { label: '${widget.label} (${widget.items.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 4281af75..ad5f8385 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -213,8 +213,8 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { label: 'Databases (${widget.databases.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, @@ -340,8 +340,8 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, @@ -535,8 +535,8 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { label: 'Schemas (${widget.schemas.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, @@ -691,8 +691,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { label: widget.schemaName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, @@ -1009,8 +1009,8 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { label: '${widget.label} (${widget.items.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 5bccba40..ec01f98b 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -157,8 +157,8 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 2e01abfa..472ae2d2 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -174,8 +174,8 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 8a540a91..f8ba2a11 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -253,8 +253,8 @@ class _FolderTileState extends State<_FolderTile> { children: [ material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 18, diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 1456068c..767fcb7f 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -169,8 +169,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: 16, @@ -362,8 +362,8 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { label: '${widget.label} (${widget.items.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, diff --git a/test/core/motion/querya_animated_expand_test.dart b/test/core/motion/querya_animated_expand_test.dart index 362a3941..c39090f3 100644 --- a/test/core/motion/querya_animated_expand_test.dart +++ b/test/core/motion/querya_animated_expand_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; void main() { testWidgets('QueryaAnimatedExpand hides child when collapsed', @@ -23,6 +24,18 @@ void main() { expect(find.text('child'), findsOneWidget); }); + + testWidgets('uses treeExpand duration/curve tokens', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: _ExpandHost(expanded: true), + ), + ); + + final size = tester.widget(find.byType(AnimatedSize)); + expect(size.duration, QueryaMotion.treeExpand); + expect(size.curve, QueryaMotion.treeExpandCurve); + }); } class _ExpandHost extends StatelessWidget { diff --git a/test/core/motion/querya_motion_test.dart b/test/core/motion/querya_motion_test.dart index a7d91d0c..aa26700f 100644 --- a/test/core/motion/querya_motion_test.dart +++ b/test/core/motion/querya_motion_test.dart @@ -11,6 +11,7 @@ void main() { expect(QueryaMotion.fast, const Duration(milliseconds: 120)); expect(QueryaMotion.standard, const Duration(milliseconds: 200)); expect(QueryaMotion.slow, const Duration(milliseconds: 320)); + expect(QueryaMotion.treeExpand, QueryaMotion.standard); }); test('curve constants are set', () { @@ -18,6 +19,7 @@ void main() { expect(QueryaMotion.exit, Curves.easeInCubic); expect(QueryaMotion.standardCurve, Curves.easeInOutCubic); expect(QueryaMotion.emphasized, Curves.easeInOutCubicEmphasized); + expect(QueryaMotion.treeExpandCurve, QueryaMotion.enter); }); }); From 9a2e3f922f59ddff7cc2e26045b3abdb34a52671 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 14:27:54 +0300 Subject: [PATCH 11/44] ui(motion): reserve springsEnabled for real SpringSimulation (#481) Stop using Full-motion springsEnabled to pick emphasized cubics on shell morphs; FadeSlide, SwitchingBody, and dialogs always use standard/enter/exit tokens. Document the rule in motion docs and CONTRIBUTING. --- CONTRIBUTING.md | 7 ++++--- docs/motion-and-high-refresh.md | 15 +++++++++++++++ lib/core/motion/querya_fade_slide.dart | 15 +++++---------- lib/core/motion/querya_spring.dart | 8 ++++++-- lib/core/motion/querya_switching_body.dart | 13 +++++-------- lib/shared/widgets/app_dialog.dart | 6 +----- test/core/motion/querya_fade_slide_test.dart | 8 ++++---- test/core/motion/querya_switching_body_test.dart | 5 ++--- 8 files changed, 42 insertions(+), 35 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 627d55d3..a64b09b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,10 +81,11 @@ version locally to avoid "works on my machine" drift. When bumping the pin, run For animated UI, **do not invent magic `Duration(...)` / raw curves** in widgets. -- Use `QueryaMotion` tokens (`fast` / `standard` / `slow`) via +- Use `QueryaMotion` tokens (`fast` / `standard` / `slow` / `treeExpand`) via `context.motionDuration` / `context.motionCurve` (or `QueryaMotion.effective*`). -- Interactive Fluid motion: `QueryaSpring` / `QueryaSpringController` when - `QueryaSpring.springsEnabled` (Full motion only). +- **Real springs only:** `QueryaSpring` / `QueryaSpringController` when + `QueryaSpring.springsEnabled` (Full motion) — tab indicator, drag settle, etc. + Do not use `springsEnabled` just to pick an emphasized cubic for fades/dialogs. - Honor Preferences Motion Full / Reduced / Off and OS `disableAnimations`. - Mid-drag layout (split panes) stays 1:1; spring settle only on drag-end. - Do not animate virtualized grid rows on scroll. diff --git a/docs/motion-and-high-refresh.md b/docs/motion-and-high-refresh.md index cb77c4d9..0204610a 100644 --- a/docs/motion-and-high-refresh.md +++ b/docs/motion-and-high-refresh.md @@ -112,6 +112,16 @@ Introduce `lib/core/motion/` with a single source of truth for durations and cur - **Theme switch**: enable a tasteful `emphasized` cross-fade and consider making it on-by-default. - **List/grid item insertion** (results, history): subtle staggered fade-in for first paint only (no per-scroll cost). +### 4.5 Springs vs duration-token cubics (#481) + +`QueryaSpring.springsEnabled` (Full motion only) means **real** `SpringSimulation` / +`QueryaSpringController` — tab strip indicator, split drag settle, and similar +interruptible physics. + +Shell morphs (`QueryaFadeSlide`, `QueryaSwitchingBody`, `showAppDialog`) always use +duration tokens (`standard` + `enter`/`exit`). Do **not** treat `emphasized` cubic +as a stand-in for “Fluid spring.” + --- ## 5. Implementation plan (proposed issues) @@ -156,6 +166,11 @@ When reviewing PRs that touch animation: 5. Tree expand: chevron `AnimatedRotation` and `QueryaAnimatedExpand` **must** use `QueryaMotion.treeExpand` + `treeExpandCurve` (not `fast`/`standardCurve` mixed with `standard`/`enter`). +6. **Springs vs cubics (#481):** `QueryaSpring.springsEnabled` gates **real** + `SpringSimulation` / `QueryaSpringController` only (tab strip indicator, split + drag settle). Shell morphs (`QueryaFadeSlide`, `QueryaSwitchingBody`, + `showAppDialog`) use duration-token cubics (`standard`/`enter`/`exit`) — do not + brand emphasized ease as “spring”. **Allowed named non-token durations** (named + documented — not magic literals at call sites): diff --git a/lib/core/motion/querya_fade_slide.dart b/lib/core/motion/querya_fade_slide.dart index c7d19086..d954b6d5 100644 --- a/lib/core/motion/querya_fade_slide.dart +++ b/lib/core/motion/querya_fade_slide.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'querya_motion.dart'; import 'querya_motion_context.dart'; -import 'querya_spring.dart'; /// Fades and optionally slides [child] when the keyed child changes. /// -/// Uses a short spring-like curve when [QueryaSpring.springsEnabled], otherwise -/// duration tokens. Prefer wrapping content with a stable [Key] on [child]. +/// Uses duration-token cubic curves ([QueryaMotion.standard] / [QueryaMotion.enter]), +/// not [QueryaSpring] — reserve springs for interruptible physics (tab indicator, +/// drag settle). Prefer wrapping content with a stable [Key] on [child]. class QueryaFadeSlide extends StatelessWidget { const QueryaFadeSlide({ super.key, @@ -24,13 +24,8 @@ class QueryaFadeSlide extends StatelessWidget { @override Widget build(BuildContext context) { - final useSpring = QueryaSpring.springsEnabled(context); - final duration = context.motionDuration( - useSpring ? QueryaMotion.standard : QueryaMotion.fast, - ); - final curve = context.motionCurve( - useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, - ); + final duration = context.motionDuration(QueryaMotion.standard); + final curve = context.motionCurve(QueryaMotion.enter); return AnimatedSwitcher( duration: duration, diff --git a/lib/core/motion/querya_spring.dart b/lib/core/motion/querya_spring.dart index 278d35fb..f53b85ee 100644 --- a/lib/core/motion/querya_spring.dart +++ b/lib/core/motion/querya_spring.dart @@ -6,8 +6,12 @@ import 'querya_motion_scope.dart'; /// Spring presets for Fluid UI (interruptible / redirectable motion). /// /// Tuned toward critically damped motion (~Apple Response 0.3–0.5s feel). -/// Use with [SpringSimulation] / [AnimationController.animateWith], not fixed -/// [Duration] curves, when [springsEnabled] is true. +/// Use **only** with [SpringSimulation] / [AnimationController.animateWith] +/// when [springsEnabled] is true (tab indicator, drag settle, etc.). +/// +/// Do **not** branch on [springsEnabled] merely to pick an emphasized cubic +/// curve for [AnimatedOpacity] / [AnimatedSwitcher] / dialogs — those use +/// [QueryaMotion] duration tokens instead (#481). abstract final class QueryaSpring { /// Snappy panels / dialogs / tab indicator (~0.3s Response feel). static const SpringDescription snappy = SpringDescription( diff --git a/lib/core/motion/querya_switching_body.dart b/lib/core/motion/querya_switching_body.dart index 77a54f91..a16a72bb 100644 --- a/lib/core/motion/querya_switching_body.dart +++ b/lib/core/motion/querya_switching_body.dart @@ -2,12 +2,14 @@ import 'package:flutter/material.dart'; import 'querya_motion.dart'; import 'querya_motion_context.dart'; -import 'querya_spring.dart'; /// Keep-alive indexed stack with opacity (+ optional slide) transitions. /// /// Off-screen children stay mounted (SQL editor state, etc.). Prefer this over /// hard `if` swaps for empty↔workspace and similar shell morphs. +/// +/// Uses duration-token cubics ([QueryaMotion.standard] / enter / exit), not +/// [QueryaSpring] — springs stay for interruptible physics only. class QueryaSwitchingBody extends StatelessWidget { const QueryaSwitchingBody({ super.key, @@ -26,13 +28,8 @@ class QueryaSwitchingBody extends StatelessWidget { Widget build(BuildContext context) { assert(children.isNotEmpty, 'QueryaSwitchingBody requires children'); final safeIndex = index.clamp(0, children.length - 1); - final useSpring = QueryaSpring.springsEnabled(context); - final duration = context.motionDuration( - useSpring ? QueryaMotion.standard : QueryaMotion.fast, - ); - final inCurve = context.motionCurve( - useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, - ); + final duration = context.motionDuration(QueryaMotion.standard); + final inCurve = context.motionCurve(QueryaMotion.enter); final outCurve = context.motionCurve(QueryaMotion.exit); return Stack( diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index 1a92ceaa..ec8a2a37 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; -import 'package:querya_desktop/core/motion/querya_spring.dart'; /// Shows a modal dialog with a frosted, dimmed backdrop over the app. /// @@ -76,12 +75,9 @@ class _BlurredDialogScaffoldState extends State<_BlurredDialogScaffold> { void _rebuildCurved() { _curved?.dispose(); - final useSpring = QueryaSpring.springsEnabled(context); _curved = CurvedAnimation( parent: widget.animation, - curve: context.motionCurve( - useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, - ), + curve: context.motionCurve(QueryaMotion.enter), reverseCurve: context.motionCurve(QueryaMotion.exit), ); } diff --git a/test/core/motion/querya_fade_slide_test.dart b/test/core/motion/querya_fade_slide_test.dart index 112d03f1..0c67cc93 100644 --- a/test/core/motion/querya_fade_slide_test.dart +++ b/test/core/motion/querya_fade_slide_test.dart @@ -92,7 +92,7 @@ void main() { expect(find.text('a'), findsNothing); }); - testWidgets('uses standard duration when springs enabled (full)', + testWidgets('uses standard/enter duration tokens (full motion)', (tester) async { await tester.pumpWidget( wrap( @@ -104,10 +104,10 @@ void main() { final switcher = tester.widget(find.byType(AnimatedSwitcher)); expect(switcher.duration, QueryaMotion.standard); - expect(switcher.switchInCurve, QueryaMotion.emphasized); + expect(switcher.switchInCurve, QueryaMotion.enter); }); - testWidgets('uses fast duration when reduced (no springs)', (tester) async { + testWidgets('halves standard duration when reduced', (tester) async { await tester.pumpWidget( wrap( const QueryaFadeSlide( @@ -122,7 +122,7 @@ void main() { switcher.duration, QueryaMotion.effectiveDuration( tester.element(find.byType(QueryaFadeSlide)), - QueryaMotion.fast, + QueryaMotion.standard, ), ); expect(switcher.switchInCurve, QueryaMotion.enter); diff --git a/test/core/motion/querya_switching_body_test.dart b/test/core/motion/querya_switching_body_test.dart index ecab84c6..d39b686d 100644 --- a/test/core/motion/querya_switching_body_test.dart +++ b/test/core/motion/querya_switching_body_test.dart @@ -228,8 +228,7 @@ void main() { expect(opacity.duration, QueryaMotion.instant); }); - testWidgets('reduced motion disables springs path (fast halved)', - (tester) async { + testWidgets('halves standard duration when reduced', (tester) async { await tester.pumpWidget( wrap( const QueryaSwitchingBody( @@ -246,7 +245,7 @@ void main() { opacity.duration, QueryaMotion.effectiveDuration( tester.element(find.byType(QueryaSwitchingBody)), - QueryaMotion.fast, + QueryaMotion.standard, ), ); }); From 521572344bc2c8d6c4ff42e1c53e556c0da2f893 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 14:35:33 +0300 Subject: [PATCH 12/44] ui(motion): adopt HoverSurface on type cards; quiet update badge (#482) Wire QueryaHoverSurface into new-connection DB type cards (optional border) and scale update-badge pulse under Reduced (half period, lower amplitude). --- docs/motion-and-high-refresh.md | 2 +- lib/core/motion/querya_hover_surface.dart | 3 + .../connections/new_connection_dialog.dart | 136 ++++++++---------- .../updater/update_available_badge.dart | 30 +++- .../motion/querya_hover_surface_test.dart | 16 +++ .../updater/update_available_badge_test.dart | 32 +++++ 6 files changed, 134 insertions(+), 85 deletions(-) diff --git a/docs/motion-and-high-refresh.md b/docs/motion-and-high-refresh.md index 0204610a..dfd24006 100644 --- a/docs/motion-and-high-refresh.md +++ b/docs/motion-and-high-refresh.md @@ -177,7 +177,7 @@ When reviewing PRs that touch animation: | Constant | Value | Where | |----------|-------|--------| | `kQueryaStaggerStep` | 30 ms | `QueryaStagger` first-paint choreography | -| `kUpdateBadgePulsePeriod` | 1400 ms | Update title-bar chip pulse (chrome; see #363) | +| `kUpdateBadgePulsePeriod` | 1400 ms | Update title-bar chip pulse at Full; Reduced halves via `effectiveDuration`; Off / OS disable stop (#363, #482) | Checklist for 120 Hz verification: [perf-baseline.md](perf-baseline.md) § Fluid shell. diff --git a/lib/core/motion/querya_hover_surface.dart b/lib/core/motion/querya_hover_surface.dart index 40c3b1c1..8db6875e 100644 --- a/lib/core/motion/querya_hover_surface.dart +++ b/lib/core/motion/querya_hover_surface.dart @@ -9,6 +9,7 @@ class QueryaHoverSurface extends StatefulWidget { super.key, required this.child, this.borderRadius, + this.border, this.padding, this.hoveredColor, this.idleColor = Colors.transparent, @@ -18,6 +19,7 @@ class QueryaHoverSurface extends StatefulWidget { final Widget child; final BorderRadius? borderRadius; + final BoxBorder? border; final EdgeInsetsGeometry? padding; final Color? hoveredColor; final Color idleColor; @@ -46,6 +48,7 @@ class _QueryaHoverSurfaceState extends State { decoration: BoxDecoration( color: _hovered ? hovered : widget.idleColor, borderRadius: widget.borderRadius, + border: widget.border, ), child: widget.child, ); diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 25995f45..dc26a23f 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -5,8 +5,7 @@ import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; -import 'package:querya_desktop/core/motion/querya_motion.dart'; -import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/motion/querya_hover_surface.dart'; import 'package:querya_desktop/features/connections/connection_type_choice.dart'; import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -385,7 +384,7 @@ class _FilterDropdowns extends StatelessWidget { } } -class _DbTypeCard extends material.StatefulWidget { +class _DbTypeCard extends material.StatelessWidget { const _DbTypeCard({ required this.choice, required this.theme, @@ -398,89 +397,70 @@ class _DbTypeCard extends material.StatefulWidget { final bool selected; final VoidCallback onTap; - @override - material.State<_DbTypeCard> createState() => _DbTypeCardState(); -} - -class _DbTypeCardState extends material.State<_DbTypeCard> { - bool _hovered = false; - @override material.Widget build(material.BuildContext context) { - final t = widget.theme; - final highlighted = widget.selected || _hovered; - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - cursor: material.SystemMouseCursors.click, - child: material.GestureDetector( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.enter), - padding: - const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8), - decoration: material.BoxDecoration( - color: highlighted - ? t.muted.withValues(alpha: 0.4) - : t.muted.withValues(alpha: 0.12), - borderRadius: material.BorderRadius.circular(10), - border: material.Border.all( - color: widget.selected - ? t.primary.withValues(alpha: 0.6) - : t.border.withValues(alpha: 0.35), - width: widget.selected ? 1.5 : 1, + final t = theme; + final highlight = t.muted.withValues(alpha: 0.4); + return QueryaHoverSurface( + borderRadius: material.BorderRadius.circular(10), + padding: + const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8), + idleColor: selected ? highlight : t.muted.withValues(alpha: 0.12), + hoveredColor: highlight, + border: material.Border.all( + color: selected + ? t.primary.withValues(alpha: 0.6) + : t.border.withValues(alpha: 0.35), + width: selected ? 1.5 : 1, + ), + onTap: onTap, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded( + child: material.Center( + child: material.SizedBox( + width: 52, + height: 52, + child: DriverIcon( + filePath: choice.iconFile, + assetPath: choice.iconAsset, + size: 52, + fallbackIcon: choice.icon, + ), + ), ), ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Expanded( - child: material.Center( - child: material.SizedBox( - width: 52, - height: 52, - child: DriverIcon( - filePath: widget.choice.iconFile, - assetPath: widget.choice.iconAsset, - size: 52, - fallbackIcon: widget.choice.icon, + const material.SizedBox(height: 6), + material.LayoutBuilder( + builder: (context, lc) { + return material.SizedBox( + height: 38, + child: material.FittedBox( + fit: material.BoxFit.scaleDown, + alignment: material.Alignment.center, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + maxWidth: math.max(48.0, lc.maxWidth), ), - ), - ), - ), - const material.SizedBox(height: 6), - material.LayoutBuilder( - builder: (context, lc) { - return material.SizedBox( - height: 38, - child: material.FittedBox( - fit: material.BoxFit.scaleDown, - alignment: material.Alignment.center, - child: material.ConstrainedBox( - constraints: material.BoxConstraints( - maxWidth: math.max(48.0, lc.maxWidth), - ), - child: material.Text( - widget.choice.label, - textAlign: material.TextAlign.center, - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 13, - fontWeight: material.FontWeight.w600, - height: 1.2, - color: t.foreground, - ), - ), + child: material.Text( + choice.label, + textAlign: material.TextAlign.center, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + height: 1.2, + color: t.foreground, ), ), - ); - }, - ), - ], + ), + ), + ); + }, ), - ), + ], ), ); } diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart index 72384c19..832de9b8 100644 --- a/lib/features/updater/update_available_badge.dart +++ b/lib/features/updater/update_available_badge.dart @@ -9,7 +9,10 @@ import 'package:querya_desktop/features/updater/update_controller.dart'; import 'package:querya_desktop/features/updater/update_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -/// Soft pulse period for the update chip (documented chrome constant; see F9). +/// Soft pulse period for the update chip at Full motion (see F9 / #482). +/// +/// Under [QueryaMotionLevel.reduced] the effective period is halved via +/// [QueryaMotion.effectiveDuration]; Off / OS `disableAnimations` stop the pulse. const Duration kUpdateBadgePulsePeriod = Duration(milliseconds: 1400); /// Pulsing title-bar chip when a background update check finds a newer release. @@ -29,6 +32,9 @@ class UpdateAvailableBadgeState extends material.State @visibleForTesting bool get isPulseAnimating => _pulse.isAnimating; + @visibleForTesting + Duration? get pulseDuration => _pulse.duration; + @override void initState() { super.initState(); @@ -73,7 +79,14 @@ class UpdateAvailableBadgeState extends material.State _pulse.value = 0; return; } - if (!_pulse.isAnimating) { + + final period = + QueryaMotion.effectiveDuration(context, kUpdateBadgePulsePeriod); + final periodChanged = _pulse.duration != period; + if (periodChanged) { + _pulse.duration = period; + } + if (!_pulse.isAnimating || periodChanged) { _pulse.repeat(reverse: true); } } @@ -95,6 +108,11 @@ class UpdateAvailableBadgeState extends material.State final wb = context.workbench; // Depend on motion so Off/Reduced rebuilds re-sync the pulse. context.motionDuration(QueryaMotion.fast); + final reduced = + QueryaMotionScope.maybeOf(context) == QueryaMotionLevel.reduced; + // Quieter chrome under Reduced (#482). + final fillAmp = reduced ? 0.04 : 0.08; + final borderAmp = reduced ? 0.12 : 0.25; return material.Padding( padding: const material.EdgeInsets.only(right: 8), @@ -115,12 +133,12 @@ class UpdateAvailableBadgeState extends material.State padding: const material.EdgeInsets.symmetric( horizontal: 10, vertical: 4), decoration: material.BoxDecoration( - color: - wb.accent.withValues(alpha: 0.12 + 0.08 * _pulse.value), + color: wb.accent + .withValues(alpha: 0.12 + fillAmp * _pulse.value), borderRadius: material.BorderRadius.circular(999), border: material.Border.all( - color: - wb.accent.withValues(alpha: 0.35 + 0.25 * _pulse.value), + color: wb.accent + .withValues(alpha: 0.35 + borderAmp * _pulse.value), ), ), child: child, diff --git a/test/core/motion/querya_hover_surface_test.dart b/test/core/motion/querya_hover_surface_test.dart index e0afb04b..d2a86c5d 100644 --- a/test/core/motion/querya_hover_surface_test.dart +++ b/test/core/motion/querya_hover_surface_test.dart @@ -155,4 +155,20 @@ void main() { ); expect(region.cursor, SystemMouseCursors.click); }); + + testWidgets('applies optional border on decoration', (tester) async { + await tester.pumpWidget( + wrap( + QueryaHoverSurface( + border: Border.all(color: const Color(0xFF445566), width: 2), + child: const SizedBox(width: 40, height: 20), + ), + ), + ); + final animated = + tester.widget(find.byType(AnimatedContainer)); + final decoration = animated.decoration! as BoxDecoration; + expect(decoration.border, isA()); + expect((decoration.border! as Border).top.width, 2); + }); } diff --git a/test/features/updater/update_available_badge_test.dart b/test/features/updater/update_available_badge_test.dart index 5e859b0a..6f61d05a 100644 --- a/test/features/updater/update_available_badge_test.dart +++ b/test/features/updater/update_available_badge_test.dart @@ -89,4 +89,36 @@ void main() { expect(find.textContaining('v1.0.0 available'), findsOneWidget); expect(state.isPulseAnimating, isFalse); }); + + testWidgets('reduced motion pulses at half period', (tester) async { + controller.setPendingUpdate( + const UpdateManifest( + version: '2.0.0', + changelog: '', + assets: [], + ), + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.reduced, + child: material.Scaffold( + body: UpdateAvailableBadge(controller: controller), + ), + ), + ), + ); + await tester.pump(); + + final state = tester.state( + find.byType(UpdateAvailableBadge), + ); + expect(find.textContaining('v2.0.0 available'), findsOneWidget); + expect(state.isPulseAnimating, isTrue); + expect( + state.pulseDuration, + Duration(microseconds: kUpdateBadgePulsePeriod.inMicroseconds ~/ 2), + ); + }); } From 4a6c48aa785f3bf102f4475e89c4df3b2e172fb9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 14:43:19 +0300 Subject: [PATCH 13/44] ui(motion): Extension Manager TabStrip + CrossFadeStack (#488) Align extension dialog tabs with workspace homes: sliding QueryaTabStrip and QueryaCrossFadeStack instead of SecondaryButton + IndexedStack. --- .../pages/extension_manager_dialog.dart | 33 +++++-------------- .../extensions/extension_manager_test.dart | 3 ++ 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 2fce9aca..7349b78c 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; import 'package:querya_desktop/features/extensions/presentation/widgets/extension_sideload_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -214,19 +215,19 @@ class _ExtensionManagerContentState material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 24.0, vertical: 8.0), - child: material.Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildTabButton(0, 'Installed', count: _installed.length), - _buildTabButton(1, 'Marketplace'), - _buildTabButton(2, 'Updates'), + child: QueryaTabStrip( + labels: [ + 'Installed (${_installed.length})', + 'Marketplace', + 'Updates', ], + selectedIndex: _tabIndex, + onSelected: (index) => setState(() => _tabIndex = index), ), ), material.Divider(height: 1, color: theme.border), material.Expanded( - child: material.IndexedStack( + child: QueryaCrossFadeStack( index: _tabIndex, children: [ _buildInstalledTab(), @@ -243,22 +244,6 @@ class _ExtensionManagerContentState ); } - material.Widget _buildTabButton(int index, String label, {int? count}) { - final isSelected = _tabIndex == index; - final displayLabel = count != null ? '$label ($count)' : label; - return SecondaryButton( - onPressed: () => setState(() => _tabIndex = index), - child: material.Text( - displayLabel, - style: material.TextStyle( - color: isSelected ? Theme.of(context).colorScheme.primary : null, - fontWeight: - isSelected ? material.FontWeight.w600 : material.FontWeight.w400, - ), - ), - ); - } - material.Widget _buildInstalledTab() { if (_loading) { return const material.Center( diff --git a/test/features/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart index bc68c223..4905121a 100644 --- a/test/features/extensions/extension_manager_test.dart +++ b/test/features/extensions/extension_manager_test.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -114,6 +115,8 @@ void main() { expect(find.text('Installed (0)'), findsOneWidget); expect(find.text('Marketplace'), findsOneWidget); expect(find.text('Install from file…'), findsOneWidget); + expect(find.byType(QueryaTabStrip), findsOneWidget); + expect(find.byType(QueryaCrossFadeStack), findsOneWidget); // Switch to Marketplace tab await tester.tap(find.text('Marketplace')); From 037d0c4ef73a4fb1caea1c539f559ea3c6f02ab5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 16:04:09 +0300 Subject: [PATCH 14/44] fix(ui): replace CheckboxListTile under opaque dialog chrome (#491 #492) Avoid Flutter 3.44 ListTile/DecoratedBox ink asserts in Preferences and SDUI forms by using Checkbox + text rows with a local transparent Material. --- lib/core/sdui/sdui_form_builder.dart | 44 ++++++++--- .../settings/preferences_controls.dart | 73 +++++++++++++++++++ lib/features/settings/preferences_dialog.dart | 11 +-- test/core/sdui/sdui_builders_test.dart | 6 ++ .../workspace_homes_and_preferences_test.dart | 2 + .../preferences_checkbox_row_test.dart | 42 +++++++++++ 6 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 test/features/settings/preferences_checkbox_row_test.dart diff --git a/lib/core/sdui/sdui_form_builder.dart b/lib/core/sdui/sdui_form_builder.dart index d34f3699..39632c96 100644 --- a/lib/core/sdui/sdui_form_builder.dart +++ b/lib/core/sdui/sdui_form_builder.dart @@ -162,15 +162,41 @@ class SduiFormBuilderState extends material.State { material.Widget _buildField(SduiFormField field) { switch (field.type) { case SduiFieldType.checkbox: - return material.CheckboxListTile( - contentPadding: material.EdgeInsets.zero, - title: Text(field.label), - value: _checkboxValues[field.id] ?? false, - controlAffinity: material.ListTileControlAffinity.leading, - onChanged: (v) { - setState(() => _checkboxValues[field.id] = v ?? false); - _notifyChanged(); - }, + // Avoid CheckboxListTile under opaque dialog DecoratedBox (Flutter 3.44+ + // ListTile ink assert — #492). + final checked = _checkboxValues[field.id] ?? false; + return material.Material( + type: material.MaterialType.transparency, + child: material.MergeSemantics( + child: material.InkWell( + onTap: () { + setState(() => _checkboxValues[field.id] = !checked); + _notifyChanged(); + }, + borderRadius: material.BorderRadius.circular(6), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + material.SizedBox( + width: 24, + height: 24, + child: material.Checkbox( + value: checked, + materialTapTargetSize: + material.MaterialTapTargetSize.shrinkWrap, + visualDensity: material.VisualDensity.compact, + onChanged: (v) { + setState(() => _checkboxValues[field.id] = v ?? false); + _notifyChanged(); + }, + ), + ), + const Gap(12), + material.Expanded(child: Text(field.label)), + ], + ), + ), + ), ); case SduiFieldType.select: return material.Column( diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index 2418fe6e..a897eaa2 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -28,6 +28,79 @@ class PreferencesHint extends StatelessWidget { } } +/// Leading checkbox + title/subtitle for Preferences (no [ListTile]). +/// +/// Avoids Flutter 3.44+ asserts when Preferences chrome uses an opaque +/// [DecoratedBox] above Material ink (#491). +class PreferencesCheckboxRow extends StatelessWidget { + const PreferencesCheckboxRow({ + super.key, + required this.value, + required this.onChanged, + required this.title, + this.subtitle, + }); + + final bool value; + final material.ValueChanged? onChanged; + final material.Widget title; + final material.Widget? subtitle; + + @override + material.Widget build(material.BuildContext context) { + final enabled = onChanged != null; + void toggle() { + if (enabled) onChanged!(!value); + } + + return material.Material( + type: material.MaterialType.transparency, + child: material.MergeSemantics( + child: material.InkWell( + onTap: enabled ? toggle : null, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 4), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SizedBox( + width: 24, + height: 24, + child: material.Checkbox( + value: value, + onChanged: enabled + ? (v) { + if (v != null) onChanged!(v); + } + : null, + materialTapTargetSize: + material.MaterialTapTargetSize.shrinkWrap, + visualDensity: material.VisualDensity.compact, + ), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + title, + if (subtitle != null) ...[ + const material.SizedBox(height: 2), + subtitle!, + ], + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + /// Label + full-width control row for Preferences (uniform dropdown width). class PreferencesFieldRow extends StatelessWidget { const PreferencesFieldRow({ diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 3a3924ab..7302877d 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -152,21 +152,16 @@ class _PreferencesDialogContentState .small() .foreground(), const material.SizedBox(height: 8), - material.CheckboxListTile( - contentPadding: material.EdgeInsets.zero, - controlAffinity: - material.ListTileControlAffinity.leading, + PreferencesCheckboxRow( + value: _checkUpdatesOnStartup, title: const Text( 'Automatically check for updates on startup', ).small(), subtitle: const Text( 'Queries GitHub Releases silently when Querya starts.', ).muted().xSmall(), - value: _checkUpdatesOnStartup, onChanged: (v) { - if (v != null) { - unawaited(_setCheckUpdatesOnStartup(v)); - } + unawaited(_setCheckUpdatesOnStartup(v)); }, ), const material.SizedBox(height: 24), diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart index 20e00f93..9bf1f381 100644 --- a/test/core/sdui/sdui_builders_test.dart +++ b/test/core/sdui/sdui_builders_test.dart @@ -118,6 +118,12 @@ void main() { expect(values['port'], 5432); expect(values['ssl'], isFalse); expect(key.currentState!.passwordFieldIds, ['password']); + expect(find.byType(material.CheckboxListTile), findsNothing); + expect(find.byType(material.Checkbox), findsOneWidget); + + await tester.tap(find.byType(material.Checkbox)); + await tester.pump(); + expect(key.currentState!.snapshotValues()['ssl'], isTrue); }); testWidgets('file_picker uses injectable picker', (tester) async { diff --git a/test/features/main_screen/workspace_homes_and_preferences_test.dart b/test/features/main_screen/workspace_homes_and_preferences_test.dart index 27f4d17f..dce57565 100644 --- a/test/features/main_screen/workspace_homes_and_preferences_test.dart +++ b/test/features/main_screen/workspace_homes_and_preferences_test.dart @@ -181,6 +181,8 @@ void main() { await tester.pump(const Duration(milliseconds: 400)); expect(find.text('Preferences'), findsOneWidget); + // Dialog chrome uses opaque DecoratedBox — must not host ListTile (#491). + expect(find.byType(material.CheckboxListTile), findsNothing); }); }); } diff --git a/test/features/settings/preferences_checkbox_row_test.dart b/test/features/settings/preferences_checkbox_row_test.dart new file mode 100644 index 00000000..412f90c3 --- /dev/null +++ b/test/features/settings/preferences_checkbox_row_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + testWidgets('PreferencesCheckboxRow toggles without CheckboxListTile', + (tester) async { + var value = false; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.StatefulBuilder( + builder: (context, setState) { + return PreferencesCheckboxRow( + value: value, + title: const Text('Toggle me').small(), + subtitle: const Text('Hint').muted().xSmall(), + onChanged: (v) => setState(() => value = v), + ); + }, + ), + ), + ), + ); + + expect(find.byType(material.CheckboxListTile), findsNothing); + expect(find.byType(material.Checkbox), findsOneWidget); + expect(tester.widget(find.byType(material.Checkbox)).value, + isFalse); + + await tester.tap(find.text('Toggle me')); + await tester.pump(); + expect(value, isTrue); + + await tester.tap(find.byType(material.Checkbox)); + await tester.pump(); + expect(value, isFalse); + }); +} From 61e1706df27434366f84b7e7aa914cf37186f2fc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 16:12:15 +0300 Subject: [PATCH 15/44] ui(motion): TabStrip Reduced uses cubic indicator (#493) QueryaSpringController gains optional cubicDuration when springs are off; TabStrip wires halved fast/enter under Reduced and keeps Off as snap. --- lib/core/motion/querya_spring_controller.dart | 90 +++++++++++++++++-- lib/shared/widgets/querya_tab_strip.dart | 19 +++- test/core/motion/querya_spring_test.dart | 44 +++++++++ test/shared/querya_tab_strip_test.dart | 39 ++++++++ 4 files changed, 182 insertions(+), 10 deletions(-) diff --git a/lib/core/motion/querya_spring_controller.dart b/lib/core/motion/querya_spring_controller.dart index e6342721..be401721 100644 --- a/lib/core/motion/querya_spring_controller.dart +++ b/lib/core/motion/querya_spring_controller.dart @@ -8,14 +8,19 @@ import 'querya_spring.dart'; /// Drives a scalar with interruptible / redirectable spring motion. /// /// Call [animateTo] to retarget; the current presentation value and velocity -/// are preserved (no brick-wall). When [useSprings] is false, snaps via -/// [jumpTo]. +/// are preserved (no brick-wall). +/// +/// When [useSprings] is false: +/// - if [cubicDuration] is non-null and non-zero → duration-token cubic (#493) +/// - otherwise → snap via [jumpTo] (Off / drag settle) class QueryaSpringController extends ChangeNotifier { QueryaSpringController({ required TickerProvider vsync, double value = 0, this.spring = QueryaSpring.snappy, this.useSprings = true, + this.cubicDuration, + this.cubicCurve = Curves.easeOutCubic, }) : _value = value, _target = value { _ticker = vsync.createTicker(_onTick); @@ -24,6 +29,10 @@ class QueryaSpringController extends ChangeNotifier { SpringDescription spring; bool useSprings; + /// Cubic fallback when springs are off (Reduced motion). Null / zero → snap. + Duration? cubicDuration; + Curve cubicCurve; + late final Ticker _ticker; double _value; double _velocity = 0; @@ -31,6 +40,10 @@ class QueryaSpringController extends ChangeNotifier { SpringSimulation? _simulation; Duration? _simulationStart; + double? _cubicFrom; + Duration? _cubicTotal; + Curve? _cubicActiveCurve; + double get value => _value; double get velocity => _velocity; double get target => _target; @@ -39,8 +52,7 @@ class QueryaSpringController extends ChangeNotifier { /// Instantly sets value (and clears velocity). void jumpTo(double value) { _ticker.stop(); - _simulation = null; - _simulationStart = null; + _clearMotion(); _velocity = 0; _target = value; if (_value == value) return; @@ -54,7 +66,12 @@ class QueryaSpringController extends ChangeNotifier { final startVelocity = velocity ?? _velocity; if (!useSprings) { - jumpTo(target); + final duration = cubicDuration; + if (duration == null || duration == Duration.zero) { + jumpTo(target); + return; + } + _startCubic(target, duration); return; } @@ -63,6 +80,9 @@ class QueryaSpringController extends ChangeNotifier { return; } + _cubicFrom = null; + _cubicTotal = null; + _cubicActiveCurve = null; _simulation = QueryaSpring.simulation( description: spring, start: _value, @@ -75,7 +95,37 @@ class QueryaSpringController extends ChangeNotifier { } } + void _startCubic(double target, Duration duration) { + if ((_value - target).abs() < 0.0001) { + jumpTo(target); + return; + } + _simulation = null; + _cubicFrom = _value; + _cubicTotal = duration; + _cubicActiveCurve = cubicCurve; + _velocity = 0; + _simulationStart = null; + if (!_ticker.isActive) { + _ticker.start(); + } + } + + void _clearMotion() { + _simulation = null; + _simulationStart = null; + _cubicFrom = null; + _cubicTotal = null; + _cubicActiveCurve = null; + } + void _onTick(Duration elapsed) { + final cubicTotal = _cubicTotal; + if (cubicTotal != null) { + _onCubicTick(elapsed, cubicTotal); + return; + } + final simulation = _simulation; if (simulation == null) { _ticker.stop(); @@ -93,8 +143,7 @@ class QueryaSpringController extends ChangeNotifier { if (settled) { _value = _target; _velocity = 0; - _simulation = null; - _simulationStart = null; + _clearMotion(); _ticker.stop(); notifyListeners(); return; @@ -104,6 +153,33 @@ class QueryaSpringController extends ChangeNotifier { notifyListeners(); } + void _onCubicTick(Duration elapsed, Duration cubicTotal) { + _simulationStart ??= elapsed; + final micros = cubicTotal.inMicroseconds; + if (micros <= 0) { + jumpTo(_target); + return; + } + final t = + (elapsed - _simulationStart!).inMicroseconds / micros; + final from = _cubicFrom ?? _value; + final curve = _cubicActiveCurve ?? cubicCurve; + + if (t >= 1) { + _value = _target; + _velocity = 0; + _clearMotion(); + _ticker.stop(); + notifyListeners(); + return; + } + + final curved = curve.transform(t.clamp(0.0, 1.0)); + _value = from + (_target - from) * curved; + _velocity = 0; + notifyListeners(); + } + @override void dispose() { _ticker.dispose(); diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index 947072bd..d91709b4 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -9,10 +9,11 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; /// A compact, keyboard-operable tab strip using Querya's motion and theme. /// -/// Selection uses a sliding pill indicator (spring when [QueryaSpring.springsEnabled]) -/// so tab changes feel continuous / redirectable. +/// Selection uses a sliding pill indicator: spring when Full +/// ([QueryaSpring.springsEnabled]), duration-token cubic under Reduced (#493), +/// snap when Off / OS `disableAnimations`. /// -/// Spring ticks rebuild only the pill ([_TabStripIndicator]), not the tab row. +/// Spring/cubic ticks rebuild only the pill ([_TabStripIndicator]), not the tab row. class QueryaTabStrip extends material.StatefulWidget { const QueryaTabStrip({ super.key, @@ -55,6 +56,18 @@ class _QueryaTabStripState extends material.State final springs = QueryaSpring.springsEnabled(context); _indicatorLeft.useSprings = springs; _indicatorWidth.useSprings = springs; + + // Reduced: animate with halved fast token; Off / disableAnimations → snap. + Duration? cubic; + if (!springs) { + final d = context.motionDuration(QueryaMotion.fast); + if (d > Duration.zero) cubic = d; + } + final curve = context.motionCurve(QueryaMotion.enter); + _indicatorLeft.cubicDuration = cubic; + _indicatorWidth.cubicDuration = cubic; + _indicatorLeft.cubicCurve = curve; + _indicatorWidth.cubicCurve = curve; } @override diff --git a/test/core/motion/querya_spring_test.dart b/test/core/motion/querya_spring_test.dart index 0cbb61fc..db2d299a 100644 --- a/test/core/motion/querya_spring_test.dart +++ b/test/core/motion/querya_spring_test.dart @@ -251,6 +251,47 @@ void main() { expect(controller.isAnimating, isFalse); }); + testWidgets('cubics when springs off and cubicDuration set', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost( + useSprings: false, + cubicDuration: const Duration(milliseconds: 100), + onCreated: (c) => controller = c, + ), + ), + ); + + controller.animateTo(1); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + expect(controller.value, greaterThan(0)); + expect(controller.value, lessThan(1)); + expect(controller.isAnimating, isTrue); + + await tester.pumpAndSettle(); + expect(controller.value, closeTo(1, 0.001)); + expect(controller.isAnimating, isFalse); + }); + + testWidgets('cubic Duration.zero snaps', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost( + useSprings: false, + cubicDuration: Duration.zero, + onCreated: (c) => controller = c, + ), + ), + ); + + controller.animateTo(1); + expect(controller.value, 1); + expect(controller.isAnimating, isFalse); + }); + testWidgets('notifies listeners on animate', (tester) async { late QueryaSpringController controller; var notifications = 0; @@ -295,11 +336,13 @@ class _SpringHost extends StatefulWidget { required this.onCreated, this.useSprings = true, this.spring = QueryaSpring.snappy, + this.cubicDuration, }); final ValueChanged onCreated; final bool useSprings; final SpringDescription spring; + final Duration? cubicDuration; @override State<_SpringHost> createState() => _SpringHostState(); @@ -316,6 +359,7 @@ class _SpringHostState extends State<_SpringHost> vsync: this, useSprings: widget.useSprings, spring: widget.spring, + cubicDuration: widget.cubicDuration, ); widget.onCreated(_controller); } diff --git a/test/shared/querya_tab_strip_test.dart b/test/shared/querya_tab_strip_test.dart index 6253709e..3ef0399a 100644 --- a/test/shared/querya_tab_strip_test.dart +++ b/test/shared/querya_tab_strip_test.dart @@ -201,6 +201,45 @@ void main() { ); }); + testWidgets('reduced motion slides indicator with cubic (not snap)', + (tester) async { + var selected = 0; + await tester.pumpWidget( + stripShell( + level: QueryaMotionLevel.reduced, + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: QueryaTabStrip( + labels: const ['Server', 'SQL', 'History'], + selectedIndex: selected, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + final startLeft = indicatorOf(tester).left!; + + await tester.tap(find.bySemanticsLabel('History')); + await tester.pump(); + await tester.pump(); // post-frame sync starts cubic + await tester.pump(const Duration(milliseconds: 20)); + + final midLeft = indicatorOf(tester).left!; + expect(midLeft, greaterThan(startLeft)); + final history = + tester.getRect(find.byKey(const material.ValueKey('querya_tab_History'))); + final strip = tester.getRect(find.byType(QueryaTabStrip)); + final endLeft = history.left - strip.left; + expect(midLeft, lessThan(endLeft - 0.5)); + + await tester.pumpAndSettle(); + expect(indicatorOf(tester).left, closeTo(endLeft, 1.0)); + }); + testWidgets('redirect mid-slide settles on final selection', (tester) async { var selected = 0; await tester.pumpWidget( From 700c44084fd4e2eb54eb70378e1958490a74cb74 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 16:18:39 +0300 Subject: [PATCH 16/44] =?UTF-8?q?ui(motion):=20morph=20connection=20A?= =?UTF-8?q?=E2=86=92B=20switches=20in=20WorkspacePanel=20(#494)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the connected slot in QueryaFadeSlide keyed by connection id so switching connections cross-fades without disturbing empty↔connected or home↔object morphs. --- lib/features/main_screen/workspace_panel.dart | 15 ++++++- .../workspace_panel_layout_test.dart | 44 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index bf9f3e92..c33bd719 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -133,6 +133,9 @@ class _WorkspacePanelState extends State { /// Keeps the last connected workspace mounted so empty↔active can cross-fade. material.Widget? _cachedActiveBody; + /// Connection id for the cached active body (stable FadeSlide key on empty). + int? _lastConnectedId; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -152,17 +155,27 @@ class _WorkspacePanelState extends State { if (activeConn == null) { activeBody = _cachedActiveBody ?? const material.SizedBox.expand(); } else { + _lastConnectedId = activeConn.id; activeBody = _buildActiveConnectionBody(theme, activeConn); _cachedActiveBody = activeBody; } + // Connection A→B: keyed FadeSlide inside the connected slot (#494). + // Keep last id when deselected so empty↔connected SwitchingBody is undisturbed. + final connKey = activeConn?.id ?? _lastConnectedId ?? 0; + return material.Container( color: theme.colorScheme.background, child: QueryaSwitchingBody( index: activeConn == null ? 0 : 1, children: [ empty, - material.SizedBox.expand(child: activeBody), + QueryaFadeSlide( + child: material.SizedBox.expand( + key: ValueKey('ws_conn_$connKey'), + child: activeBody, + ), + ), ], ), ); diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index 38111323..79f795ce 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -211,5 +211,49 @@ void main() { expect(find.byType(RedisView), findsOneWidget); expect(find.byType(QueryaSwitchingBody), findsNWidgets(2)); }); + + testWidgets('connection A→B morph uses outer QueryaFadeSlide', + (tester) async { + const redisB = ConnectionRow( + id: 43, + type: 'redis', + name: 'redis-b', + host: '127.0.0.1', + port: 6380, + createdAt: '0', + ); + + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(activeConnection: redisConnection), + ), + ), + ); + await tester.pump(); + + expect(find.byKey(const material.ValueKey('ws_conn_42')), findsOneWidget); + expect(find.byType(RedisView), findsOneWidget); + + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(activeConnection: redisB), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byKey(const material.ValueKey('ws_conn_43')), findsOneWidget); + // Outer empty↔connected FadeSlide + home↔object FadeSlide. + expect(find.byType(QueryaFadeSlide), findsWidgets); + expect(find.byType(RedisView), findsOneWidget); + expect(find.byType(QueryaSwitchingBody), findsNWidgets(2)); + }); }); } From 950fb2c47956989f7c974d9515bec82c1970e6df Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 16:24:01 +0300 Subject: [PATCH 17/44] ui(extensions): honest Updates tab until Marketplace API (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop claiming all extensions are up to date. Show loading while data loads, then a clear “not available yet” empty state with reinstall guidance. --- .../pages/extension_manager_dialog.dart | 33 +++++++++++++++++-- .../extensions/extension_manager_test.dart | 13 ++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 7349b78c..d87455d1 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -403,10 +403,37 @@ class _ExtensionManagerContentState } material.Widget _buildUpdatesTab() { - return const material.Center( + if (_loading) { + return const material.Center( + child: material.CircularProgressIndicator(), + ); + } + final theme = Theme.of(context).colorScheme; + return material.Center( child: material.Padding( - padding: material.EdgeInsets.all(32.0), - child: Text('All installed extensions are up to date!'), + padding: const material.EdgeInsets.all(32.0), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(maxWidth: 420), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.system_update_alt_rounded, + size: 36, + color: theme.mutedForeground, + ), + const material.SizedBox(height: 16), + const Text('Extension update checks are not available yet') + .semiBold(), + const material.SizedBox(height: 8), + const Text( + 'Automatic update scanning ships with the Marketplace API. ' + 'Until then, reinstall from Marketplace or from a local file ' + 'to get a newer build.', + ).muted().small(), + ], + ), + ), ), ); } diff --git a/test/features/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart index 4905121a..4fc3efa8 100644 --- a/test/features/extensions/extension_manager_test.dart +++ b/test/features/extensions/extension_manager_test.dart @@ -125,6 +125,19 @@ void main() { expect(find.text('ClickHouse Driver'), findsOneWidget); expect(find.textContaining('preview listings only'), findsOneWidget); expect(find.text('Preview'), findsWidgets); + + await tester.tap(find.text('Updates')); + await tester.pumpAndSettle(); + + expect( + find.text('All installed extensions are up to date!'), + findsNothing, + ); + expect( + find.text('Extension update checks are not available yet'), + findsOneWidget, + ); + expect(find.textContaining('Marketplace API'), findsOneWidget); }); }); } From 5cf918d5ca8c36a20fbda3551adbcf04fc4d21e0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 16:29:04 +0300 Subject: [PATCH 18/44] ui(trees): tokenize sidebar header chevrons and connection icons (#496) Add sidebarExpand / sidebarConnectionIcon tokens; use QueryaIcons.expandClosed on all connection and folder headers; drop hard-coded 16/18 chevron sizes. --- lib/core/ui/querya_icon_sizes.dart | 6 ++++++ .../connections_panel_extension.dart | 12 +++++------ .../connections/connections_panel_mongo.dart | 13 ++++++------ .../connections/connections_panel_mysql.dart | 11 +++++----- ...connections_panel_postgres_connection.dart | 13 ++++++------ .../connections/connections_panel_redis.dart | 13 ++++++------ .../connections_panel_sidebar.dart | 21 ++++++++++++------- .../connections/connections_panel_sqlite.dart | 11 +++++----- test/core/ui/querya_icons_test.dart | 12 +++++++++-- 9 files changed, 68 insertions(+), 44 deletions(-) diff --git a/lib/core/ui/querya_icon_sizes.dart b/lib/core/ui/querya_icon_sizes.dart index 6a9e48dc..58c66d81 100644 --- a/lib/core/ui/querya_icon_sizes.dart +++ b/lib/core/ui/querya_icon_sizes.dart @@ -9,6 +9,12 @@ abstract final class QueryaIconSizes { /// Expand chevron in tree rows. static const double treeExpand = 13; + /// Expand chevron on connection / folder headers in the sidebar (#496). + static const double sidebarExpand = 16; + + /// Connection-type icon / logo on sidebar header rows. + static const double sidebarConnectionIcon = 16; + /// Database / connection-level tree nodes. static const double treeConnection = 14; diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 05077770..88902940 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -141,25 +141,25 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { if (_iconFilePath != null) { iconWidget = DriverIconImage( path: _iconFilePath!, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, fallbackIcon: widget.icon, ); } else if (widget.iconAsset != null) { iconWidget = material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ); } else { iconWidget = material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ); } @@ -185,7 +185,7 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, - size: 16, + size: QueryaIconSizes.sidebarExpand, color: theme.colorScheme.mutedForeground, ), ), diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index a1193b36..db1f4f8c 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -142,17 +142,18 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) : material.Icon(widget.icon, - size: 16, color: theme.colorScheme.primary); + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary); return ContextMenu( items: [ @@ -200,8 +201,8 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { duration: context.motionDuration(QueryaMotion.treeExpand), curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, color: theme.colorScheme.mutedForeground, ), ), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 069001bd..41f5b5db 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -107,17 +107,18 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) : material.Icon(widget.icon, - size: 16, color: theme.colorScheme.primary); + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary); return ContextMenu( items: [ @@ -166,7 +167,7 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, - size: 16, + size: QueryaIconSizes.sidebarExpand, color: theme.colorScheme.mutedForeground, ), ), diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index ec01f98b..f592e3c6 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -110,17 +110,18 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) : material.Icon(widget.icon, - size: 16, color: theme.colorScheme.primary); + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary); return ContextMenu( items: [ @@ -160,8 +161,8 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { duration: context.motionDuration(QueryaMotion.treeExpand), curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, color: theme.colorScheme.mutedForeground, ), ), diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 472ae2d2..cdb0aa4e 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -125,17 +125,18 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) : material.Icon(widget.icon, - size: 16, color: theme.colorScheme.primary); + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary); return ContextMenu( items: [ @@ -177,8 +178,8 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { duration: context.motionDuration(QueryaMotion.treeExpand), curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, color: theme.colorScheme.mutedForeground, ), ), diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index f8ba2a11..98229949 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -91,16 +91,20 @@ class _ConnectionTile extends StatelessWidget { final iconWidget = iconAsset != null ? material.Image.asset( iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) - : material.Icon(icon, size: 16, color: theme.colorScheme.primary); + : material.Icon( + icon, + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary, + ); return ContextMenu( items: [ MenuButton( @@ -256,14 +260,15 @@ class _FolderTileState extends State<_FolderTile> { duration: context.motionDuration(QueryaMotion.treeExpand), curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 18, + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, color: theme.colorScheme.mutedForeground, ), ), const Gap(2), - material.Icon(material.Icons.folder_rounded, - size: 18, color: theme.colorScheme.primary), + material.Icon(QueryaIcons.folder, + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary), const Gap(8), material.Expanded( child: material.Text( diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 767fcb7f..50db8265 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -111,17 +111,18 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) : material.Icon(widget.icon, - size: 16, color: theme.colorScheme.primary); + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary); return ContextMenu( items: [ @@ -173,7 +174,7 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, - size: 16, + size: QueryaIconSizes.sidebarExpand, color: theme.colorScheme.mutedForeground, ), ), diff --git a/test/core/ui/querya_icons_test.dart b/test/core/ui/querya_icons_test.dart index 7f93df88..20025fa5 100644 --- a/test/core/ui/querya_icons_test.dart +++ b/test/core/ui/querya_icons_test.dart @@ -79,8 +79,16 @@ void main() { }); }); - test('tree size tokens are ordered leaf < group < sdui', () { + test('tree size tokens are ordered leaf < group < connection < sidebar', () { expect(QueryaIconSizes.treeLeaf, lessThan(QueryaIconSizes.treeGroup)); - expect(QueryaIconSizes.treeGroup, lessThan(QueryaIconSizes.sduiNode)); + expect(QueryaIconSizes.treeGroup, lessThan(QueryaIconSizes.treeConnection)); + expect( + QueryaIconSizes.treeExpand, + lessThan(QueryaIconSizes.sidebarExpand), + ); + expect( + QueryaIconSizes.sidebarExpand, + QueryaIconSizes.sidebarConnectionIcon, + ); }); } From 48837294c08e9a67d2da85073bd6c8f10f8c2858 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 16:38:53 +0300 Subject: [PATCH 19/44] ui(icons): drop unused QueryaIconSizes.sduiNode (#497) SDUI trees already share native treeGroup/treeLeaf sizes; remove the dead 16px token so docs/tests match implementation. --- lib/core/sdui/sdui_tree_builder.dart | 1 + lib/core/ui/querya_icon_sizes.dart | 3 --- test/core/ui/querya_icons_test.dart | 6 ++++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index 4324062e..5cb31f85 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -202,6 +202,7 @@ class SduiTreeBuilderState extends material.State { final isLoading = _loading.contains(node.id); final nodeKind = _resolveNodeKind(node); final isBrowsable = nodeKind == 'table' || nodeKind == 'view'; + // Same hierarchy as native trees (#476 / #497) — no separate sduiNode size. final iconSize = canExpand ? QueryaIconSizes.treeGroup : QueryaIconSizes.treeLeaf; final iconColor = isBrowsable ? primary.withValues(alpha: 0.5) : muted; diff --git a/lib/core/ui/querya_icon_sizes.dart b/lib/core/ui/querya_icon_sizes.dart index 58c66d81..90746d07 100644 --- a/lib/core/ui/querya_icon_sizes.dart +++ b/lib/core/ui/querya_icon_sizes.dart @@ -21,9 +21,6 @@ abstract final class QueryaIconSizes { /// Inline tree error indicator. static const double treeError = 14; - /// SDUI explorer tree nodes. - static const double sduiNode = 16; - /// Menu / dialog leading icons. static const double menuLeading = 18; } diff --git a/test/core/ui/querya_icons_test.dart b/test/core/ui/querya_icons_test.dart index 20025fa5..c9847d2c 100644 --- a/test/core/ui/querya_icons_test.dart +++ b/test/core/ui/querya_icons_test.dart @@ -91,4 +91,10 @@ void main() { QueryaIconSizes.sidebarConnectionIcon, ); }); + + test('SDUI trees share native treeGroup/treeLeaf sizes (no sduiNode)', () { + // Guards against reintroducing a dead parallel size token (#497). + expect(QueryaIconSizes.treeGroup, 13); + expect(QueryaIconSizes.treeLeaf, 12); + }); } From 392735da54256558f9cb0f5c1428ea8bfc873250 Mon Sep 17 00:00:00 2001 From: Reei-dp Date: Tue, 28 Jul 2026 17:44:51 +0400 Subject: [PATCH 20/44] ci(packaging): automate AUR publish on Release (#507) Add aur-publish workflow, publish-aur job in Release, PKGBUILD sync in version-bump, and scripts/linux/aur_publish.sh for push + .SRCINFO generation. --- .github/workflows/aur-publish.yml | 47 ++++++++++++++ .github/workflows/release.yml | 43 ++++++++++++- .github/workflows/version-bump.yml | 11 +++- docs/packaging.md | 2 +- docs/tags-and-releases.md | 2 +- packaging/linux/aur/PKGBUILD | 7 +- packaging/linux/aur/README.md | 41 ++++++++---- scripts/linux/aur_publish.sh | 100 +++++++++++++++++++++++++++++ 8 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/aur-publish.yml create mode 100755 scripts/linux/aur_publish.sh diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml new file mode 100644 index 00000000..ad8ef9c9 --- /dev/null +++ b/.github/workflows/aur-publish.yml @@ -0,0 +1,47 @@ +# Manual AUR publish (hotfix / re-publish). Normal path: Release workflow → publish-aur job. +# +# First push creates the AUR repo automatically. Requires secret AUR_SSH_PRIVATE_KEY. + +name: Publish AUR (querya-desktop) + +on: + workflow_dispatch: + inputs: + version: + description: 'pkgver (e.g. 0.4.12) — Querya-Desktop-{version}-linux.zip must exist on GitHub Releases' + required: true + type: string + release_tag: + description: 'GitHub Release tag if different from version (e.g. v0.4.12); leave empty to auto-try' + required: false + type: string + +jobs: + aur: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Require AUR SSH secret + env: + KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + if [ -z "${KEY:-}" ]; then + echo "Missing repository secret AUR_SSH_PRIVATE_KEY" >&2 + exit 1 + fi + + - name: SSH agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + + - name: Publish to AUR + env: + RELEASE_TAG: ${{ github.event.inputs.release_tag }} + run: | + set -euo pipefail + VER="${{ github.event.inputs.version }}" + TAG="${RELEASE_TAG:-$VER}" + chmod +x ./scripts/linux/aur_publish.sh + ./scripts/linux/aur_publish.sh "$VER" "$TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddee1899..a1f569f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -269,6 +269,9 @@ jobs: name: Publish GitHub Release needs: [build-windows, build-linux, build-macos] runs-on: ubuntu-latest + outputs: + version: ${{ needs.build-windows.outputs.version }} + release_tag: ${{ steps.rel.outputs.tag }} steps: - uses: actions/checkout@v4 with: @@ -320,7 +323,7 @@ jobs: echo "- **Linux Flatpak**: \`Querya-Desktop-${VERSION}-linux.flatpak\` — \`flatpak install --user ./Querya-Desktop-${VERSION}-linux.flatpak\`" echo "- **Windows setup**: \`Querya-Desktop-${VERSION}-windows-setup.exe\` (Inno Setup)" echo "" - echo "**Arch (AUR):** PKGBUILD in \`packaging/linux/aur/\` (community-maintained)." + echo "**Arch (AUR):** \`querya-desktop\` — \`yay -S querya-desktop\` (auto-published when \`AUR_SSH_PRIVATE_KEY\` is configured)." echo "" echo "Verify checksums: \`SHA256SUMS.txt\`" echo "" @@ -355,3 +358,41 @@ jobs: dist/SHA256SUMS.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-aur: + name: Publish AUR (querya-desktop) + needs: [publish] + runs-on: ubuntu-latest + steps: + - name: Check AUR secret + id: aur + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + if [ -n "${AUR_SSH_PRIVATE_KEY}" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + - name: Wait for release assets + if: steps.aur.outputs.enabled == 'true' + run: sleep 15 + + - uses: actions/checkout@v4 + if: steps.aur.outputs.enabled == 'true' + + - name: SSH agent + if: steps.aur.outputs.enabled == 'true' + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + + - name: Publish to AUR + if: steps.aur.outputs.enabled == 'true' + run: | + set -euo pipefail + VER="${{ needs.publish.outputs.version }}" + TAG="${{ needs.publish.outputs.release_tag }}" + chmod +x ./scripts/linux/aur_publish.sh + ./scripts/linux/aur_publish.sh "$VER" "$TAG" diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index e8c4f824..ae4d82a8 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -80,11 +80,20 @@ jobs: sed -i "s/^version: .*/version: ${NEW_VERSION}+${NEW_BUILD}/" pubspec.yaml grep "^version:" pubspec.yaml + - name: Sync AUR PKGBUILD pkgver with pubspec + if: steps.check.outputs.merged == 'true' + run: | + NEW_VERSION="${{ steps.bump.outputs.new_version }}" + sed -i "s/^pkgver=.*/pkgver=${NEW_VERSION}/" packaging/linux/aur/PKGBUILD + sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/linux/aur/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('SKIP' 'SKIP' 'SKIP')/" packaging/linux/aur/PKGBUILD + grep ^pkgver packaging/linux/aur/PKGBUILD + - name: Commit version bump if: steps.check.outputs.merged == 'true' run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" - git add pubspec.yaml + git add pubspec.yaml packaging/linux/aur/PKGBUILD git commit -m "Bump version to ${{ steps.bump.outputs.new_version }}+${{ steps.bump.outputs.new_build }}" git push origin main diff --git a/docs/packaging.md b/docs/packaging.md index 0c0fb260..040322ce 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -65,7 +65,7 @@ payload as the portable zip and AppImage. | `.deb` | [`scripts/linux/build_deb.sh`](../scripts/linux/build_deb.sh) | `sudo apt install ./Querya-Desktop-{ver}-linux.deb` | | `.rpm` | [`scripts/linux/build_rpm.sh`](../scripts/linux/build_rpm.sh) | `sudo dnf install ./Querya-Desktop-{ver}-linux.rpm` | | Flatpak | [`scripts/linux/build_flatpak.sh`](../scripts/linux/build_flatpak.sh) | `flatpak install --user ./Querya-Desktop-{ver}-linux.flatpak` | -| AUR | [`packaging/linux/aur/`](../packaging/linux/aur/) | Community PKGBUILD (Release zip under `/opt`) | +| AUR | [`packaging/linux/aur/`](../packaging/linux/aur/) | `yay -S querya-desktop` (CI publishes on Release when `AUR_SSH_PRIVATE_KEY` is set) | **Runtime dependencies (deb/rpm):** GTK 3, libsecret, GLib; app indicator recommended for tray. diff --git a/docs/tags-and-releases.md b/docs/tags-and-releases.md index 2d9b0621..50a01a8b 100644 --- a/docs/tags-and-releases.md +++ b/docs/tags-and-releases.md @@ -53,7 +53,7 @@ sudo dnf install ./Querya-Desktop-X.Y.Z-linux.rpm flatpak install --user ./Querya-Desktop-X.Y.Z-linux.flatpak ``` -**Arch (AUR):** community PKGBUILD — [`packaging/linux/aur/`](../packaging/linux/aur/) (installs the Release portable zip under `/opt`). +**Arch (AUR):** `yay -S querya-desktop` — auto-published by Release CI when `AUR_SSH_PRIVATE_KEY` is configured ([`packaging/linux/aur/`](../packaging/linux/aur/)). ## Changelog в GitHub Release diff --git a/packaging/linux/aur/PKGBUILD b/packaging/linux/aur/PKGBUILD index 01f085e7..f0070a5d 100644 --- a/packaging/linux/aur/PKGBUILD +++ b/packaging/linux/aur/PKGBUILD @@ -1,8 +1,8 @@ # Maintainer: QueryaHub # AUR package — installs the official Release portable Linux zip under /opt. -# Bump pkgver/pkgrel when a new GitHub Release is published. +# pkgver is synced by version-bump.yml; CI publishes real sha256sums on Release. pkgname=querya-desktop -pkgver=0.4.11-b +pkgver=0.4.12 pkgrel=1 pkgdesc="Multi-database desktop client (PostgreSQL, MySQL, Redis, MongoDB, SQLite)" arch=('x86_64') @@ -15,8 +15,9 @@ optdepends=( source=( "Querya-Desktop-${pkgver}-linux.zip::https://github.com/QueryaHub/Querya-Desktop/releases/download/${pkgver}/Querya-Desktop-${pkgver}-linux.zip" "querya_desktop.desktop" + "querya_desktop.png" ) -sha256sums=('SKIP' 'SKIP') +sha256sums=('SKIP' 'SKIP' 'SKIP') prepare() { bsdtar -xf "$srcdir/Querya-Desktop-${pkgver}-linux.zip" -C "$srcdir" diff --git a/packaging/linux/aur/README.md b/packaging/linux/aur/README.md index fdcfdd8b..ae555733 100644 --- a/packaging/linux/aur/README.md +++ b/packaging/linux/aur/README.md @@ -1,19 +1,38 @@ # Arch Linux (AUR) -Community packaging for Arch-based distros. The PKGBUILD installs the official -**portable Linux zip** from GitHub Releases under `/opt/querya-desktop`. +Official **`querya-desktop`** package on AUR. Installs the Release portable Linux zip +under `/opt/querya-desktop`. -## Before publishing to AUR +## Install -1. Copy `PKGBUILD`, `querya_desktop.desktop`, and `querya_desktop.png` into a clean build directory. -2. Bump `pkgver` / `pkgrel` to match the GitHub Release tag and AUR revision. -3. Run `makepkg -si` locally and smoke-launch `querya_desktop`. -4. Generate `.SRCINFO`: `makepkg --printsrcinfo > .SRCINFO` -5. Push to your AUR repo (e.g. `querya-desktop`). +```bash +yay -S querya-desktop +# or: paru -S querya-desktop +``` -`querya_desktop.desktop` and the 512×512 icon are the same assets used by -`.deb` / `.rpm` packaging (`packaging/linux/querya_desktop.desktop` and -`macos/Runner/Assets.xcassets/.../app_icon_512.png`). +## CI publish + +| Trigger | Workflow | +|---------|----------| +| After GitHub Release | [Release](../../.github/workflows/release.yml) → job `publish-aur` | +| Manual hotfix | [Publish AUR](../../.github/workflows/aur-publish.yml) | + +Requires repository secret **`AUR_SSH_PRIVATE_KEY`**. Without it, Release skips AUR +(no failed job). First `git push` creates the AUR repo automatically. + +`pkgver` in the template PKGBUILD is synced on merge to `main` by +[version-bump.yml](../../.github/workflows/version-bump.yml); CI fills real +`sha256sums` and `.SRCINFO` at publish time via +[scripts/linux/aur_publish.sh](../../scripts/linux/aur_publish.sh). + +## Local smoke test + +```bash +cp packaging/linux/aur/{PKGBUILD,querya_desktop.desktop,querya_desktop.png} /tmp/querya-aur/ +cd /tmp/querya-aur +makepkg -si +querya_desktop +``` ## Updates diff --git a/scripts/linux/aur_publish.sh b/scripts/linux/aur_publish.sh new file mode 100755 index 00000000..83b18ecc --- /dev/null +++ b/scripts/linux/aur_publish.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Push querya-desktop PKGBUILD to AUR (used by Release CI and aur-publish workflow). +# +# Prerequisites: ssh-agent loaded with AUR key; docker available for makepkg --printsrcinfo. +# +# Usage: +# aur_publish.sh [release_tag] +# +# release_tag — GitHub Release tag (tries release_tag, version, vversion for asset URL). +set -euo pipefail + +VERSION="${1:?version required}" +RELEASE_TAG="${2:-$VERSION}" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TEMPLATE="$ROOT/packaging/linux/aur" +AUR_REPO="${AUR_REPO:-querya-desktop}" +WORK="$ROOT/aur-repo" +ZIP="Querya-Desktop-${VERSION}-linux.zip" +REPO="${GITHUB_REPOSITORY:-QueryaHub/Querya-Desktop}" + +download_release_zip() { + local tag url + for tag in "$RELEASE_TAG" "$VERSION" "v${VERSION}"; do + url="https://github.com/${REPO}/releases/download/${tag}/${ZIP}" + echo "Fetching ${url}" + if curl -fsSL -o "$ZIP" "$url"; then + echo "Downloaded from tag ${tag}" + return 0 + fi + echo "retry with next tag candidate..." + sleep 5 + done + return 1 +} + +echo "AUR publish: pkgver=${VERSION} release_tag=${RELEASE_TAG}" + +cd "$ROOT" +for attempt in 1 2 3 4 5 6; do + if download_release_zip; then + break + fi + if [ "$attempt" -eq 6 ]; then + echo "error: could not download ${ZIP}" >&2 + exit 1 + fi + echo "retry ${attempt}..." + sleep 10 +done + +ZIP_SHA="$(sha256sum "$ZIP" | awk '{print $1}')" +DESKTOP_SHA="$(sha256sum "$TEMPLATE/querya_desktop.desktop" | awk '{print $1}')" +PNG_SHA="$(sha256sum "$TEMPLATE/querya_desktop.png" | awk '{print $1}')" + +mkdir -p ~/.ssh +ssh-keyscan -t rsa,ecdsa,ed25519 aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null + +rm -rf "$WORK" +if ! git clone "ssh://aur@aur.archlinux.org/${AUR_REPO}.git" "$WORK" 2>/dev/null; then + mkdir -p "$WORK" + git -C "$WORK" init + git -C "$WORK" remote add origin "ssh://aur@aur.archlinux.org/${AUR_REPO}.git" +fi + +cp "$TEMPLATE/PKGBUILD" "$WORK/PKGBUILD" +cp "$TEMPLATE/querya_desktop.desktop" "$WORK/querya_desktop.desktop" +cp "$TEMPLATE/querya_desktop.png" "$WORK/querya_desktop.png" + +cd "$WORK" +sed -i "s/^pkgver=.*/pkgver=${VERSION}/" PKGBUILD +sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD +sed -i "s/^sha256sums=.*/sha256sums=('${ZIP_SHA}' '${DESKTOP_SHA}' '${PNG_SHA}')/" PKGBUILD + +out="$WORK/.SRCINFO" +docker run --rm \ + -v "$WORK:/pkg" \ + archlinux:latest \ + bash -lc ' + set -euo pipefail + pacman -Syu --noconfirm --needed archlinux-keyring pacman base-devel >/dev/null 2>&1 + useradd -m -s /bin/bash builduser + chown -R builduser:builduser /pkg + runuser -u builduser -- env HOME=/home/builduser bash -lc "cd /pkg && makepkg --printsrcinfo" + ' > "$out" +sudo chown -R "$(id -u):$(id -g)" "$WORK" 2>/dev/null || true +test -s "$out" +grep -qE "^pkgbase[[:space:]]*=" "$out" || { echo "::error::Invalid .SRCINFO"; head -50 "$out"; exit 1; } + +git config user.email "github-actions[bot]@users.noreply.github.com" +git config user.name "github-actions[bot]" +git add PKGBUILD .SRCINFO querya_desktop.desktop querya_desktop.png +if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 +fi +git commit -m "chore: ${VERSION}" +git push origin HEAD:master + +echo "AUR ${AUR_REPO} updated to ${VERSION}" From fe24af9cc300b6c88d9987d61735754f51b9df0b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 21:10:11 +0300 Subject: [PATCH 21/44] ui(trees): align Mongo sidebar Databases group with other drivers (#498) Add a Databases (N) group header and treeConnection sizing/indent so Mongo matches Redis/MySQL hierarchy instead of flat DB leaves under the connection. --- .../connections/connections_panel_mongo.dart | 70 +++++++++++++++++-- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index db1f4f8c..68e991b4 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -295,12 +295,12 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { ), onRetry: _loadDatabases, ), - for (final db in _databases) - _MongoDatabaseNode( + if (_databases.isNotEmpty) + _MongoDatabasesNode( connection: widget.connection, - name: db, - onTap: () => widget.onDatabaseTap?.call(db), - onDelete: () => _deleteDatabase(db), + databases: _databases, + onDatabaseTap: widget.onDatabaseTap, + onDeleteDatabase: _deleteDatabase, onRefreshDatabases: () { setState(() => _databases = []); _loadDatabases(); @@ -316,6 +316,64 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { } } +class _MongoDatabasesNode extends StatelessWidget { + const _MongoDatabasesNode({ + required this.connection, + required this.databases, + required this.onRefreshDatabases, + required this.onDeleteDatabase, + this.onDatabaseTap, + }); + + final ConnectionRow connection; + final List databases; + final VoidCallback onRefreshDatabases; + final Future Function(String name) onDeleteDatabase; + final void Function(String database)? onDatabaseTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 20), + 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, + 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, + ); + }, + ), + ], + ), + ); + } +} + class _MongoDatabaseNode extends StatelessWidget { const _MongoDatabaseNode({ required this.connection, @@ -339,7 +397,7 @@ class _MongoDatabaseNode extends StatelessWidget { child: _PgTreeRow( label: name, icon: QueryaIcons.database, - iconSize: QueryaIconSizes.treeGroup, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, From 22a7136e4e6da3d04ce28bb01ec0a72f4a4d2aeb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 21:17:20 +0300 Subject: [PATCH 22/44] ui(settings): theme picker exit motion and compact trigger expand (#499) Delay menu close so exit fade-slide can play, matching QueryaDropdown, and expand the trigger label only when stretch is requested. --- .../settings/theme_picker_button.dart | 112 +++++++++++++++--- .../settings/theme_picker_button_test.dart | 54 +++++++++ 2 files changed, 150 insertions(+), 16 deletions(-) diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index a8b93197..01b8ceac 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -76,7 +76,10 @@ class _ThemePickerButtonState extends material.State { material.ScrollController(); final material.TextEditingController _searchController = material.TextEditingController(); + final material.ValueNotifier _menuOpen = + material.ValueNotifier(false); bool _triggerHovered = false; + bool _closingWithExit = false; String? _previewThemeId; String? _previewThemeLabel; QueryaTheme? _previewTheme; @@ -97,9 +100,26 @@ class _ThemePickerButtonState extends material.State { _searchController.removeListener(_onSearchChanged); _searchController.dispose(); _scrollController.dispose(); + _menuOpen.dispose(); super.dispose(); } + /// Plays exit fade-slide, then removes the [MenuAnchor] overlay (#499). + Future _closeWithExit() async { + if (!_controller.isOpen || _closingWithExit) return; + _closingWithExit = true; + _menuOpen.value = false; + final duration = context.motionDuration(QueryaMotion.standard); + if (duration > QueryaMotion.instant) { + await Future.delayed(duration); + } + if (!mounted) return; + if (_controller.isOpen) { + _controller.close(); + } + _closingWithExit = false; + } + void _resetPreviewState() { _previewDebounce?.cancel(); _previewRequestSerial++; @@ -192,6 +212,14 @@ class _ThemePickerButtonState extends material.State { final anchor = material.MenuAnchor( controller: _controller, + onOpen: () { + _closingWithExit = false; + _menuOpen.value = true; + }, + onClose: () { + _closingWithExit = false; + _menuOpen.value = false; + }, crossAxisUnconstrained: false, alignmentOffset: material.Offset( 0, @@ -223,10 +251,13 @@ class _ThemePickerButtonState extends material.State { ), ), menuChildren: [ - material.SizedBox( - width: menuWidth, - height: menuHeight, - child: _buildMenuPanel(context, cs), + _ThemePickerMenuEnter( + openNotifier: _menuOpen, + child: material.SizedBox( + width: menuWidth, + height: menuHeight, + child: _buildMenuPanel(context, cs), + ), ), ], builder: (context, controller, child) { @@ -354,7 +385,7 @@ class _ThemePickerButtonState extends material.State { widget.onSelected(theme.id); _clearSearch(); _resetPreviewState(); - _controller.close(); + unawaited(_closeWithExit()); }, ); }, @@ -403,16 +434,10 @@ class _ThemePickerButtonState extends material.State { ? material.MainAxisSize.max : material.MainAxisSize.min, children: [ - material.Expanded( - child: material.Text( - _triggerLabel, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: QueryaDropdownTokens.triggerTextStyle( - context, - _enabled ? cs.popoverForeground : cs.mutedForeground, - ), - ), + _triggerLabelText( + context: context, + cs: cs, + expand: widget.expandToParent || fieldWidth != null, ), material.SizedBox(width: chevronGap), material.Icon( @@ -433,7 +458,7 @@ class _ThemePickerButtonState extends material.State { onTap: _enabled ? () { if (controller.isOpen) { - controller.close(); + unawaited(_closeWithExit()); } else { _clearSearch(); _resetPreviewState(); @@ -446,6 +471,61 @@ class _ThemePickerButtonState extends material.State { ), ); } + + material.Widget _triggerLabelText({ + required material.BuildContext context, + required ColorScheme cs, + required bool expand, + }) { + final text = material.Text( + _triggerLabel, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.triggerTextStyle( + context, + _enabled ? cs.popoverForeground : cs.mutedForeground, + ), + ); + if (expand) { + return material.Expanded(child: text); + } + return text; + } +} + +/// Enter/exit fade-slide for theme menu body while the overlay stays mounted. +class _ThemePickerMenuEnter extends material.StatelessWidget { + const _ThemePickerMenuEnter({ + required this.openNotifier, + required this.child, + }); + + final material.ValueNotifier openNotifier; + final material.Widget child; + + @override + material.Widget build(material.BuildContext context) { + final duration = context.motionDuration(QueryaMotion.standard); + final enter = context.motionCurve(QueryaMotion.enter); + final exit = context.motionCurve(QueryaMotion.exit); + return material.ValueListenableBuilder( + valueListenable: openNotifier, + builder: (context, open, _) { + final curve = open ? enter : exit; + return material.AnimatedSlide( + offset: open ? material.Offset.zero : const material.Offset(0, -0.04), + duration: duration, + curve: curve, + child: material.AnimatedOpacity( + opacity: open ? 1 : 0, + duration: duration, + curve: curve, + child: child, + ), + ); + }, + ); + } } class _ThemePickerRow extends material.StatefulWidget { diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index 6defda18..fe40911b 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -581,6 +581,60 @@ void main() { expect(picked, ThemeController.builtinQueryaLightId); }); + + testWidgets('compact trigger uses min mainAxisSize (no forced Expanded)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(3), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + final rows = tester.widgetList(find.byType(material.Row)); + final triggerRow = rows.firstWhere( + (row) => row.mainAxisSize == material.MainAxisSize.min, + ); + expect(triggerRow.mainAxisSize, material.MainAxisSize.min); + expect( + triggerRow.children.whereType(), + isEmpty, + ); + }); + + testWidgets('selecting theme keeps overlay briefly for exit motion', + (tester) async { + final themes = _fakeThemes(5); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + expect(find.byType(material.ListView), findsOneWidget); + + await tester.tap(find.text('Theme 01')); + await tester.pump(); // start exit; overlay still mounted + expect(find.byType(material.ListView), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.byType(material.ListView), findsNothing); + }); }); group('filterThemeDefinitions metadata', () { From 5b7c39e8f53223cd72d07153834daf693eaec53e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 21:18:10 +0300 Subject: [PATCH 23/44] fix(macos): hide duplicate bitsdojo window buttons in title bar (#500) Keep the macOS leading inset for system traffic lights and only render Minimize/Maximize/Close on Windows and Linux. --- .../main_screen/querya_window_title_bar.dart | 14 +++++++++++--- .../main_screen/querya_window_title_bar_test.dart | 11 +++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index f611ff27..7625db02 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -68,6 +68,10 @@ class QueryaWindowTitleBar extends StatelessWidget { return scale(isMacOS ? 72 : 16); } + /// Bitsdojo chrome buttons duplicate system traffic lights on macOS. + @visibleForTesting + static bool showBitsdojoWindowButtons({required bool isMacOS}) => !isMacOS; + @visibleForTesting static WindowButtonColors closeButtonColors(BuildContext context) { final wb = context.workbench; @@ -278,9 +282,13 @@ class QueryaWindowTitleBar extends StatelessWidget { if (activeConnection != null && isReadOnly) const QueryaReadOnlyBadge(), UpdateAvailableBadge(controller: UpdateController.instance), - MinimizeWindowButton(colors: buttonColors), - MaximizeWindowButton(colors: buttonColors), - CloseWindowButton(colors: closeButtonColors), + if (QueryaWindowTitleBar.showBitsdojoWindowButtons( + isMacOS: Platform.isMacOS, + )) ...[ + MinimizeWindowButton(colors: buttonColors), + MaximizeWindowButton(colors: buttonColors), + CloseWindowButton(colors: closeButtonColors), + ], ], ) ], diff --git a/test/features/main_screen/querya_window_title_bar_test.dart b/test/features/main_screen/querya_window_title_bar_test.dart index fbe77a87..afe0a7e6 100644 --- a/test/features/main_screen/querya_window_title_bar_test.dart +++ b/test/features/main_screen/querya_window_title_bar_test.dart @@ -115,6 +115,17 @@ void main() { ); }); + test('bitsdojo window buttons hidden on macOS, shown elsewhere', () { + expect( + QueryaWindowTitleBar.showBitsdojoWindowButtons(isMacOS: true), + isFalse, + ); + expect( + QueryaWindowTitleBar.showBitsdojoWindowButtons(isMacOS: false), + isTrue, + ); + }); + testWidgets('read-only state is persistently visible in title bar', (tester) async { await tester.pumpWidget( From 9ac66173fd7ee209abca0e7a5509fdf34a31d300 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 21:28:52 +0300 Subject: [PATCH 24/44] ui: ship opportunistic P2 polish batch (#501) Unify tree leaf tints/indent/icons, align CrossFadeStack and Stagger with motion levels, stop empty-hero Quick start flash, cross-fade update dialog phases, and share tooltip wait duration. --- lib/core/motion/querya_cross_fade_stack.dart | 20 ++++++--- lib/core/motion/querya_hover_surface.dart | 4 ++ lib/core/motion/querya_stagger.dart | 6 ++- lib/core/sdui/sdui_tree_builder.dart | 13 ++++-- lib/core/ui/querya_icons.dart | 11 ++++- lib/core/ui/querya_tooltip.dart | 2 + lib/core/ui/querya_tree_tokens.dart | 11 +++++ .../connections/connections_panel.dart | 2 + .../connections/connections_panel_mysql.dart | 2 +- .../connections_panel_pg_tree.dart | 14 +++--- .../connections/connections_panel_sqlite.dart | 2 +- .../main_screen/result_grid_view.dart | 3 +- .../main_screen/workspace_empty_hero.dart | 39 +++++++++------- lib/features/updater/update_dialog.dart | 21 ++++++++- .../motion/querya_cross_fade_stack_test.dart | 45 +++++++++++++++++++ test/core/motion/querya_stagger_test.dart | 18 ++++++++ .../workspace_empty_hero_test.dart | 24 ++++++++++ 17 files changed, 196 insertions(+), 41 deletions(-) create mode 100644 lib/core/ui/querya_tooltip.dart create mode 100644 lib/core/ui/querya_tree_tokens.dart diff --git a/lib/core/motion/querya_cross_fade_stack.dart b/lib/core/motion/querya_cross_fade_stack.dart index 4527cf85..9459d9cf 100644 --- a/lib/core/motion/querya_cross_fade_stack.dart +++ b/lib/core/motion/querya_cross_fade_stack.dart @@ -5,6 +5,9 @@ import 'querya_motion_context.dart'; /// Like [IndexedStack] but cross-fades the active child; off-screen children /// stay mounted (preserves SQL editor state, etc.). +/// +/// Enter/exit curves and index clamping match [QueryaSwitchingBody] (without +/// the optional slide). class QueryaCrossFadeStack extends StatelessWidget { const QueryaCrossFadeStack({ super.key, @@ -17,8 +20,11 @@ class QueryaCrossFadeStack extends StatelessWidget { @override Widget build(BuildContext context) { + assert(children.isNotEmpty, 'QueryaCrossFadeStack requires children'); + final safeIndex = index.clamp(0, children.length - 1); final duration = context.motionDuration(QueryaMotion.standard); - final curve = context.motionCurve(QueryaMotion.enter); + final inCurve = context.motionCurve(QueryaMotion.enter); + final outCurve = context.motionCurve(QueryaMotion.exit); return Stack( fit: StackFit.expand, @@ -26,17 +32,17 @@ class QueryaCrossFadeStack extends StatelessWidget { for (var i = 0; i < children.length; i++) Positioned.fill( child: IgnorePointer( - ignoring: index != i, + ignoring: i != safeIndex, child: ExcludeFocus( - excluding: index != i, + excluding: i != safeIndex, child: ExcludeSemantics( - excluding: index != i, + excluding: i != safeIndex, child: AnimatedOpacity( - opacity: index == i ? 1 : 0, + opacity: i == safeIndex ? 1 : 0, duration: duration, - curve: curve, + curve: i == safeIndex ? inCurve : outCurve, child: TickerMode( - enabled: index == i, + enabled: i == safeIndex, child: RepaintBoundary(child: children[i]), ), ), diff --git a/lib/core/motion/querya_hover_surface.dart b/lib/core/motion/querya_hover_surface.dart index 8db6875e..be95a5b1 100644 --- a/lib/core/motion/querya_hover_surface.dart +++ b/lib/core/motion/querya_hover_surface.dart @@ -4,6 +4,10 @@ import 'querya_motion.dart'; import 'querya_motion_context.dart'; /// Unified hover background / border using motion tokens (Responsive chrome). +/// +/// **Scope:** selection / picker cards (e.g. connection type tiles). Dense +/// trees and explorer rows keep lighter `InkWell` / `MouseRegion` hover — +/// do not broaden adoption without an explicit follow-up. class QueryaHoverSurface extends StatefulWidget { const QueryaHoverSurface({ super.key, diff --git a/lib/core/motion/querya_stagger.dart b/lib/core/motion/querya_stagger.dart index 654ceda7..18735fc8 100644 --- a/lib/core/motion/querya_stagger.dart +++ b/lib/core/motion/querya_stagger.dart @@ -31,6 +31,7 @@ class _QueryaStaggerState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; bool _played = false; + Duration _effectiveStep = kQueryaStaggerStep; @override void initState() { @@ -47,12 +48,13 @@ class _QueryaStaggerState extends State if (n == 0) return; final base = context.motionDuration(QueryaMotion.fast); + _effectiveStep = context.motionDuration(widget.step); if (base == QueryaMotion.instant) { _controller.value = 1; return; } - final total = base + widget.step * n; + final total = base + _effectiveStep * n; _controller.duration = total; _controller.forward(); } @@ -96,7 +98,7 @@ class _QueryaStaggerState extends State final totalMs = _controller.duration!.inMilliseconds; if (totalMs <= 0) return 1; - final stepMs = widget.step.inMilliseconds; + final stepMs = _effectiveStep.inMilliseconds; final start = (stepMs * index) / totalMs; final end = (start + 0.35).clamp(0.0, 1.0); final t = _controller.value; diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index 5cb31f85..eccce458 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -4,6 +4,7 @@ import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_schema.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'; /// Renders a sidebar-style tree from an SDUI schema with lazy expansion. @@ -174,7 +175,9 @@ class SduiTreeBuilderState extends material.State { final row = rows[index]; if (row.isError) { return material.Padding( - padding: material.EdgeInsets.only(left: 36.0 + row.depth * 16.0), + padding: material.EdgeInsets.only( + left: 36.0 + row.depth * QueryaTreeTokens.indent, + ), child: material.Align( alignment: material.Alignment.centerLeft, child: Text(row.error!).muted().xSmall(), @@ -196,7 +199,6 @@ class SduiTreeBuilderState extends material.State { material.Widget _buildNodeRow(SduiTreeNode node, {required int depth}) { final theme = Theme.of(context); final muted = theme.colorScheme.mutedForeground; - final primary = theme.colorScheme.primary; final canExpand = node.expandable || node.hasChildren; final isExpanded = _expanded.contains(node.id); final isLoading = _loading.contains(node.id); @@ -205,8 +207,11 @@ class SduiTreeBuilderState extends material.State { // Same hierarchy as native trees (#476 / #497) — no separate sduiNode size. final iconSize = canExpand ? QueryaIconSizes.treeGroup : QueryaIconSizes.treeLeaf; - final iconColor = isBrowsable ? primary.withValues(alpha: 0.5) : muted; - final rowLeft = 8.0 + depth * 16.0 + (canExpand ? 0 : 4.0); + final iconColor = isBrowsable + ? QueryaTreeTokens.leafIconColor(theme.colorScheme) + : muted; + final rowLeft = + 8.0 + depth * QueryaTreeTokens.indent + (canExpand ? 0 : 4.0); return material.InkWell( onTap: () { diff --git a/lib/core/ui/querya_icons.dart b/lib/core/ui/querya_icons.dart index 297dcf4e..eaba0b47 100644 --- a/lib/core/ui/querya_icons.dart +++ b/lib/core/ui/querya_icons.dart @@ -14,7 +14,7 @@ abstract final class QueryaIcons { material.Icons.account_tree_rounded; static const material.IconData schema = material.Icons.diamond_rounded; static const material.IconData extension = material.Icons.extension_rounded; - static const material.IconData publicSchema = material.Icons.public_rounded; + static const material.IconData foreignData = material.Icons.hub_rounded; static const material.IconData tableGroup = material.Icons.table_chart_rounded; @@ -23,11 +23,18 @@ abstract final class QueryaIcons { static const material.IconData viewLeaf = material.Icons.view_week_rounded; static const material.IconData materializedViewGroup = material.Icons.dynamic_feed_rounded; + static const material.IconData materializedViewLeaf = + material.Icons.layers_rounded; static const material.IconData functionGroup = material.Icons.functions_rounded; static const material.IconData functionLeaf = material.Icons.code_rounded; - static const material.IconData sequence = + static const material.IconData sequenceGroup = material.Icons.format_list_numbered_rounded; + static const material.IconData sequenceLeaf = + material.Icons.looks_one_rounded; + + /// Alias kept for call sites that still say "sequence" as the group icon. + static const material.IconData sequence = sequenceGroup; static const material.IconData indexes = material.Icons.table_rows_rounded; static const material.IconData triggers = material.Icons.bolt_rounded; static const material.IconData types = material.Icons.category_rounded; diff --git a/lib/core/ui/querya_tooltip.dart b/lib/core/ui/querya_tooltip.dart new file mode 100644 index 00000000..264c8cf0 --- /dev/null +++ b/lib/core/ui/querya_tooltip.dart @@ -0,0 +1,2 @@ +/// Shared [Tooltip.waitDuration] for dense chrome (trees, grids, …). +const Duration kQueryaTooltipWait = Duration(milliseconds: 450); diff --git a/lib/core/ui/querya_tree_tokens.dart b/lib/core/ui/querya_tree_tokens.dart new file mode 100644 index 00000000..e9daae22 --- /dev/null +++ b/lib/core/ui/querya_tree_tokens.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +/// Shared connection-tree metrics and colors (PG / MySQL / SQLite / SDUI). +abstract final class QueryaTreeTokens { + /// Indent for schema rows and sibling object folders under a database. + static const double indent = 16; + + /// Leaf-row icon tint (tables, views, sequences, …). + static Color leafIconColor(ColorScheme scheme) => + scheme.primary.withValues(alpha: 0.5); +} diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 958721a8..4c3cef41 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -68,6 +68,8 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_typography.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_tooltip.dart'; +import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 41f5b5db..1af080a3 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -668,7 +668,7 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { label: item, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, - iconColor: theme.colorScheme.mutedForeground, + iconColor: QueryaTreeTokens.leafIconColor(theme.colorScheme), textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.foreground, diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index ad5f8385..f25bb71c 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -48,7 +48,7 @@ class _PgTreeRowLabel extends material.StatelessWidget { if (label.length < _tooltipMinLength) return text; return material.Tooltip( message: label, - waitDuration: const Duration(milliseconds: 450), + waitDuration: kQueryaTooltipWait, child: text, ); } @@ -381,7 +381,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { connection: widget.connection, databaseName: widget.databaseName, label: 'Foreign data', - icon: QueryaIcons.publicSchema, + icon: QueryaIcons.foreignData, kind: PostgresObjectKind.databaseForeignData, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, @@ -682,7 +682,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { Widget build(BuildContext context) { final theme = Theme.of(context); return material.Padding( - padding: const material.EdgeInsets.only(left: 12), + padding: const material.EdgeInsets.only(left: QueryaTreeTokens.indent), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, @@ -794,7 +794,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { onRefresh: _loadObjects, label: 'Materialized views', icon: QueryaIcons.materializedViewGroup, - itemIcon: QueryaIcons.materializedViewGroup, + itemIcon: QueryaIcons.materializedViewLeaf, items: _matviews, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -837,8 +837,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Sequences', - icon: QueryaIcons.sequence, - itemIcon: QueryaIcons.sequence, + icon: QueryaIcons.sequenceGroup, + itemIcon: QueryaIcons.sequenceLeaf, items: _sequences, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -1045,7 +1045,7 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { label: item, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.5), + iconColor: QueryaTreeTokens.leafIconColor(theme.colorScheme), textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.foreground, diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 50db8265..861e0cd6 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -398,7 +398,7 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { label: item, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, - iconColor: theme.colorScheme.mutedForeground, + iconColor: QueryaTreeTokens.leafIconColor(theme.colorScheme), textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.foreground, diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index 2ce785d1..9fc2becb 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/ui/querya_tooltip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Layout metrics for [VirtualResultGrid]. @@ -485,7 +486,7 @@ class _GridCell extends material.StatelessWidget { return material.Tooltip( message: text, - waitDuration: const Duration(milliseconds: 400), + waitDuration: kQueryaTooltipWait, child: interactiveCell, ); } diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index 4555d853..d254bd5e 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -182,22 +182,31 @@ class _WorkspaceEmptyHeroState extends State { QueryaFadeSlide( alignment: material.Alignment.topCenter, offset: const material.Offset(0, 0.03), - child: showRecent - ? _RecentConnectionsSection( - key: const material.ValueKey('empty_recent_section'), - connections: _recent, - onOpenConnection: widget.onOpenConnection, - compact: compact, + child: !_loaded + ? const material.SizedBox( + key: material.ValueKey('empty_section_loading'), + height: 120, ) - : _QuickStartSection( - key: const material.ValueKey('empty_quick_start'), - compact: compact, - surface: wb.surface, - borderColor: - wb.borderSubtle.withValues(alpha: 0.55), - foreground: cs.foreground, - primary: cs.primary, - ), + : showRecent + ? _RecentConnectionsSection( + key: const material.ValueKey( + 'empty_recent_section', + ), + connections: _recent, + onOpenConnection: widget.onOpenConnection, + compact: compact, + ) + : _QuickStartSection( + key: const material.ValueKey( + 'empty_quick_start', + ), + compact: compact, + surface: wb.surface, + borderColor: + wb.borderSubtle.withValues(alpha: 0.55), + foreground: cs.foreground, + primary: cs.primary, + ), ), ], ), diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index eebfb7f6..c9582d94 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/updater/app_updater_service.dart'; import 'package:querya_desktop/core/updater/update_manifest.dart'; @@ -273,7 +275,24 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { horizontal: 24, vertical: 8, ), - child: _body(context), + child: material.AnimatedSwitcher( + duration: context.motionDuration(QueryaMotion.standard), + switchInCurve: context.motionCurve(QueryaMotion.enter), + switchOutCurve: context.motionCurve(QueryaMotion.exit), + layoutBuilder: (currentChild, previousChildren) { + return material.Stack( + alignment: material.Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: material.KeyedSubtree( + key: material.ValueKey(_phase), + child: _body(context), + ), + ), ), ), material.Container( diff --git a/test/core/motion/querya_cross_fade_stack_test.dart b/test/core/motion/querya_cross_fade_stack_test.dart index 61b0ff6e..28b8b9da 100644 --- a/test/core/motion/querya_cross_fade_stack_test.dart +++ b/test/core/motion/querya_cross_fade_stack_test.dart @@ -54,4 +54,49 @@ void main() { focusNode1.dispose(); focusNode2.dispose(); }); + + testWidgets('QueryaCrossFadeStack clamps out-of-range index', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: QueryaCrossFadeStack( + index: 99, + children: [ + Text('only', key: Key('only_child')), + ], + ), + ), + ), + ); + + final opacity = + tester.widget(find.byType(AnimatedOpacity)); + expect(opacity.opacity, 1.0); + expect(find.byKey(const Key('only_child')), findsOneWidget); + }); + + testWidgets('QueryaCrossFadeStack uses exit curve when fading out', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: QueryaCrossFadeStack( + index: 0, + children: const [ + Text('a', key: Key('a')), + Text('b', key: Key('b')), + ], + ), + ), + ), + ); + + final opacities = + tester.widgetList(find.byType(AnimatedOpacity)); + expect(opacities.elementAt(0).opacity, 1.0); + expect(opacities.elementAt(1).opacity, 0.0); + // Inactive layer uses exit curve; active uses enter. + expect(opacities.elementAt(0).curve, isNot(opacities.elementAt(1).curve)); + }); } diff --git a/test/core/motion/querya_stagger_test.dart b/test/core/motion/querya_stagger_test.dart index e21be0ed..820b992c 100644 --- a/test/core/motion/querya_stagger_test.dart +++ b/test/core/motion/querya_stagger_test.dart @@ -102,6 +102,24 @@ void main() { expect(opacityOf(tester, 'item-2'), 1.0); }); + testWidgets('Reduced motion halves stagger step timing', (tester) async { + await tester.pumpWidget( + wrap( + QueryaStagger( + step: const Duration(milliseconds: 80), + children: texts(3), + ), + level: QueryaMotionLevel.reduced, + ), + ); + // Full would still have item-2 at 0 after 30ms with 80ms step; Reduced + // halves step to 40ms so later items start earlier. + await tester.pump(const Duration(milliseconds: 30)); + expect(opacityOf(tester, 'item-0'), greaterThan(0)); + await tester.pumpAndSettle(); + expect(opacityOf(tester, 'item-2'), 1.0); + }); + testWidgets('OS disableAnimations skips stagger', (tester) async { await tester.pumpWidget( MaterialApp( diff --git a/test/features/main_screen/workspace_empty_hero_test.dart b/test/features/main_screen/workspace_empty_hero_test.dart index 99a90e56..af169d75 100644 --- a/test/features/main_screen/workspace_empty_hero_test.dart +++ b/test/features/main_screen/workspace_empty_hero_test.dart @@ -73,6 +73,30 @@ void main() { expect(sqliteTapped, isTrue); }); + testWidgets('does not flash Quick start before recent load completes', + (tester) async { + await tester.pumpWidget( + heroShell( + child: material.SizedBox( + width: 900, + height: 700, + child: WorkspaceEmptyHero( + onNewConnection: () {}, + ), + ), + ), + ); + // First frame before async recent load settles. + await tester.pump(); + + expect( + find.byKey(const material.ValueKey('empty_section_loading')), + findsOneWidget, + ); + expect(find.text('Quick start'), findsNothing); + expect(find.text('Recent connections'), findsNothing); + }); + testWidgets('WorkspaceEmptyHero opens a recent connection', (tester) async { ConnectionRow? opened; From c1cde76165d5d035d98792b7a6c8fb79f736a874 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 21:31:40 +0300 Subject: [PATCH 25/44] fix(ui): take Color for tree leaf tint to avoid ColorScheme clash shadcn and Material each define ColorScheme; pass primary Color instead so analyze stays clean. --- lib/core/sdui/sdui_tree_builder.dart | 2 +- lib/core/ui/querya_tree_tokens.dart | 7 +++++-- lib/features/connections/connections_panel_mysql.dart | 4 +++- lib/features/connections/connections_panel_pg_tree.dart | 4 +++- lib/features/connections/connections_panel_sqlite.dart | 4 +++- test/core/motion/querya_cross_fade_stack_test.dart | 4 ++-- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index eccce458..8dce2670 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -208,7 +208,7 @@ class SduiTreeBuilderState extends material.State { final iconSize = canExpand ? QueryaIconSizes.treeGroup : QueryaIconSizes.treeLeaf; final iconColor = isBrowsable - ? QueryaTreeTokens.leafIconColor(theme.colorScheme) + ? QueryaTreeTokens.leafIconColor(theme.colorScheme.primary) : muted; final rowLeft = 8.0 + depth * QueryaTreeTokens.indent + (canExpand ? 0 : 4.0); diff --git a/lib/core/ui/querya_tree_tokens.dart b/lib/core/ui/querya_tree_tokens.dart index e9daae22..20ef57ca 100644 --- a/lib/core/ui/querya_tree_tokens.dart +++ b/lib/core/ui/querya_tree_tokens.dart @@ -6,6 +6,9 @@ abstract final class QueryaTreeTokens { static const double indent = 16; /// Leaf-row icon tint (tables, views, sequences, …). - static Color leafIconColor(ColorScheme scheme) => - scheme.primary.withValues(alpha: 0.5); + /// + /// Takes [primary] (not [ColorScheme]) so both Material and shadcn schemes + /// can pass `.primary` without a type clash. + static Color leafIconColor(Color primary) => + primary.withValues(alpha: 0.5); } diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 1af080a3..37a0edb3 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -668,7 +668,9 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { label: item, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, - iconColor: QueryaTreeTokens.leafIconColor(theme.colorScheme), + iconColor: QueryaTreeTokens.leafIconColor( + theme.colorScheme.primary, + ), textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.foreground, diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index f25bb71c..f0afecd2 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -1045,7 +1045,9 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { label: item, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, - iconColor: QueryaTreeTokens.leafIconColor(theme.colorScheme), + iconColor: QueryaTreeTokens.leafIconColor( + theme.colorScheme.primary, + ), textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.foreground, diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 861e0cd6..0563f513 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -398,7 +398,9 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { label: item, icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, - iconColor: QueryaTreeTokens.leafIconColor(theme.colorScheme), + iconColor: QueryaTreeTokens.leafIconColor( + theme.colorScheme.primary, + ), textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.foreground, diff --git a/test/core/motion/querya_cross_fade_stack_test.dart b/test/core/motion/querya_cross_fade_stack_test.dart index 28b8b9da..36e0b2ef 100644 --- a/test/core/motion/querya_cross_fade_stack_test.dart +++ b/test/core/motion/querya_cross_fade_stack_test.dart @@ -79,11 +79,11 @@ void main() { testWidgets('QueryaCrossFadeStack uses exit curve when fading out', (WidgetTester tester) async { await tester.pumpWidget( - MaterialApp( + const MaterialApp( home: Scaffold( body: QueryaCrossFadeStack( index: 0, - children: const [ + children: [ Text('a', key: Key('a')), Text('b', key: Key('b')), ], From 57523ba3faaf918a12e549614726cccbf65fb635 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 21:43:59 +0300 Subject: [PATCH 26/44] =?UTF-8?q?ui:=20finish=20P2=20leftovers=20=E2=80=94?= =?UTF-8?q?=20TreeLoadError,=20semantics,=20SDUI=20dropdown,=20dialog=20Ma?= =?UTF-8?q?terial=20(#514)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make tree load errors show a consistent icon/title (including SDUI), mark expand rows with button/expanded semantics, render SDUI selects via QueryaDropdown, and host dialog shells on Material popover cards. --- lib/core/sdui/sdui_form_builder.dart | 59 ++- lib/core/sdui/sdui_form_schema.dart | 3 +- lib/core/sdui/sdui_tree_builder.dart | 21 +- lib/core/sdui/sdui_tree_schema.dart | 3 +- .../connections/connections_panel.dart | 15 +- .../connections_panel_extension.dart | 37 +- .../connections/connections_panel_mongo.dart | 33 +- .../connections/connections_panel_mysql.dart | 36 +- .../connections_panel_pg_tree.dart | 29 +- ...connections_panel_postgres_connection.dart | 33 +- .../connections/connections_panel_redis.dart | 33 +- .../connections_panel_sidebar.dart | 70 ++-- .../connections/connections_panel_sqlite.dart | 34 +- .../connections/driver_manager_dialog.dart | 126 +++--- .../connections/new_connection_dialog.dart | 25 +- .../new_connection_url_dialog.dart | 172 ++++----- .../connections/new_folder_dialog.dart | 144 ++++--- .../pages/extension_manager_dialog.dart | 119 +++--- lib/features/help/about_dialog.dart | 126 +++--- .../mongodb/mongo_database_dialog.dart | 124 +++--- .../mysql/mysql_sql_editor_dialog.dart | 151 ++++---- .../postgres_sql_editor_dialog.dart | 151 ++++---- .../postgres_table_privileges_dialog.dart | 102 +++-- lib/features/settings/preferences_dialog.dart | 358 +++++++++--------- lib/features/updater/update_dialog.dart | 126 +++--- lib/shared/widgets/querya_dialog_card.dart | 38 ++ lib/shared/widgets/tree_load_error.dart | 15 +- lib/shared/widgets/widgets.dart | 1 + test/shared/widgets/tree_load_error_test.dart | 24 +- 29 files changed, 1141 insertions(+), 1067 deletions(-) create mode 100644 lib/shared/widgets/querya_dialog_card.dart diff --git a/lib/core/sdui/sdui_form_builder.dart b/lib/core/sdui/sdui_form_builder.dart index 39632c96..d218b95d 100644 --- a/lib/core/sdui/sdui_form_builder.dart +++ b/lib/core/sdui/sdui_form_builder.dart @@ -129,9 +129,8 @@ class SduiFormBuilderState extends material.State { Future _pickFile(SduiFormField field) async { final picker = widget.filePicker; - final path = picker != null - ? await picker(field) - : (await openFile())?.path; + final path = + picker != null ? await picker(field) : (await openFile())?.path; if (path == null || !mounted) return; _textControllers[field.id]?.text = path; _notifyChanged(); @@ -199,28 +198,52 @@ class SduiFormBuilderState extends material.State { ), ); case SduiFieldType.select: + final options = field.options; + final current = _selectValues[field.id] ?? + (options.isNotEmpty ? options.first.value : ''); return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text(field.label).small().semiBold(), const Gap(4), - material.DropdownButtonFormField( - initialValue: _selectValues[field.id], - items: [ - for (final opt in field.options) - material.DropdownMenuItem( - value: opt.value, - child: material.Text(opt.label), - ), - ], - onChanged: (v) { - setState(() => _selectValues[field.id] = v); - _notifyChanged(); - }, + material.FormField( + initialValue: current, validator: field.required - ? (v) => - (v == null || v.isEmpty) ? '${field.label} is required' : null + ? (v) => (v == null || v.isEmpty) + ? '${field.label} is required' + : null : null, + builder: (state) { + final value = state.value ?? current; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + QueryaDropdown( + value: value.isEmpty && options.isNotEmpty + ? options.first.value + : value, + expandToParent: true, + items: [ + for (final opt in options) + QueryaDropdownItem( + value: opt.value, + label: opt.label, + ), + ], + onSelected: (v) { + final next = v ?? value; + setState(() => _selectValues[field.id] = next); + state.didChange(next); + _notifyChanged(); + }, + ), + if (state.hasError) ...[ + const Gap(4), + Text(state.errorText!).xSmall().muted(), + ], + ], + ); + }, ), ], ); diff --git a/lib/core/sdui/sdui_form_schema.dart b/lib/core/sdui/sdui_form_schema.dart index ed6e14ed..21a3cfe1 100644 --- a/lib/core/sdui/sdui_form_schema.dart +++ b/lib/core/sdui/sdui_form_schema.dart @@ -60,7 +60,8 @@ class SduiFormField { if (item is Map) { options.add(SduiSelectOption.fromJson(item)); } else if (item is Map) { - options.add(SduiSelectOption.fromJson(Map.from(item))); + options + .add(SduiSelectOption.fromJson(Map.from(item))); } else if (item != null) { options.add(SduiSelectOption(value: '$item', label: '$item')); } diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index 8dce2670..fafc2455 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -169,18 +169,19 @@ class SduiTreeBuilderState extends material.State { physics: widget.maxHeight == null ? const material.NeverScrollableScrollPhysics() : const material.ClampingScrollPhysics(), - itemExtent: _rowExtent, + itemExtent: rows.any((r) => r.isError) ? null : _rowExtent, itemCount: rows.length, itemBuilder: (context, index) { final row = rows[index]; if (row.isError) { - return material.Padding( + return TreeLoadError( + title: 'Could not expand', + message: row.error!, + detailFontSize: 10, padding: material.EdgeInsets.only( left: 36.0 + row.depth * QueryaTreeTokens.indent, - ), - child: material.Align( - alignment: material.Alignment.centerLeft, - child: Text(row.error!).muted().xSmall(), + top: 2, + bottom: 2, ), ); } @@ -213,7 +214,7 @@ class SduiTreeBuilderState extends material.State { final rowLeft = 8.0 + depth * QueryaTreeTokens.indent + (canExpand ? 0 : 4.0); - return material.InkWell( + final row = material.InkWell( onTap: () { if (isBrowsable) { widget.onNodeSelected?.call(node); @@ -284,6 +285,12 @@ class SduiTreeBuilderState extends material.State { ), ), ); + if (!canExpand) return row; + return material.Semantics( + button: true, + expanded: isExpanded, + child: row, + ); } String _resolveNodeKind(SduiTreeNode node) { diff --git a/lib/core/sdui/sdui_tree_schema.dart b/lib/core/sdui/sdui_tree_schema.dart index d530654a..843cdefa 100644 --- a/lib/core/sdui/sdui_tree_schema.dart +++ b/lib/core/sdui/sdui_tree_schema.dart @@ -93,4 +93,5 @@ class SduiTreeSchema { } /// Loads children for an expandable node (`fetchTreeChildren` RPC). -typedef SduiFetchTreeChildren = Future> Function(String nodeId); +typedef SduiFetchTreeChildren = Future> Function( + String nodeId); diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 4c3cef41..1730bc14 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -38,6 +38,7 @@ import 'package:flutter/material.dart' as material Expanded, CircularProgressIndicator, Material, + Semantics, StatelessWidget, Colors, Tooltip, @@ -349,11 +350,17 @@ class ConnectionsPanelState extends State { _expandedConnections.remove(id); }); if (conn.type == 'postgresql') { - PostgresService.instance.interrupt(conn, database: conn.databaseName ?? 'postgres', mode: PgSessionMode.readOnly); - PostgresService.instance.interrupt(conn, database: conn.databaseName ?? 'postgres', mode: PgSessionMode.readWrite); + PostgresService.instance.interrupt(conn, + database: conn.databaseName ?? 'postgres', + mode: PgSessionMode.readOnly); + PostgresService.instance.interrupt(conn, + database: conn.databaseName ?? 'postgres', + mode: PgSessionMode.readWrite); } else if (conn.type == 'mysql') { - MysqlService.instance.interrupt(conn, database: conn.databaseName ?? '', mode: MysqlSessionMode.readOnly); - MysqlService.instance.interrupt(conn, database: conn.databaseName ?? '', mode: MysqlSessionMode.readWrite); + MysqlService.instance.interrupt(conn, + database: conn.databaseName ?? '', mode: MysqlSessionMode.readOnly); + MysqlService.instance.interrupt(conn, + database: conn.databaseName ?? '', mode: MysqlSessionMode.readWrite); } else if (conn.type == 'sqlite') { SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readOnly); SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readWrite); diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 88902940..036a9dc0 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -91,8 +91,8 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { _error = null; }); try { - final schema = - await ExtensionDriverSession.instance.getSchemaTree(widget.connection); + final schema = await ExtensionDriverSession.instance + .getSchemaTree(widget.connection); if (!mounted) return; setState(() { _schema = schema; @@ -174,19 +174,25 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { children: [ material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: widget.isExpanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.treeExpand), - curve: context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: widget.isExpanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: widget.isExpanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), @@ -288,6 +294,7 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { ) else if (_error != null) TreeLoadError( + title: 'Could not load extension tree', message: _error!, padding: const material.EdgeInsets.only( left: 28, diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index 68e991b4..d745ccf8 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -191,19 +191,25 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { // Expand/collapse arrow material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.treeExpand), - curve: context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), @@ -285,7 +291,6 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { TreeLoadError( title: 'Could not load databases', message: _error!, - showTitleRow: true, detailFontSize: 10, padding: const material.EdgeInsets.only( left: 28, diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 37a0edb3..a6f08dc1 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -156,19 +156,25 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { children: [ material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.treeExpand), - curve: context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), @@ -246,6 +252,7 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { ), if (_error != null) TreeLoadError( + title: 'Could not load databases', message: _error!, padding: const material.EdgeInsets.only( left: 28, @@ -457,6 +464,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { color: theme.colorScheme.foreground, ), verticalPadding: 4, + expanded: _expanded, onTap: _toggle, connection: widget.connection, onContextRefresh: _loadTables, @@ -490,6 +498,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { ) else if (_error != null) TreeLoadError( + title: 'Could not load tables', message: _error!, onRetry: _loadTables, ), @@ -648,6 +657,7 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { fontSize: 11, color: theme.colorScheme.mutedForeground, ), + expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), connection: widget.connection, onContextRefresh: widget.onRefresh, diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index f0afecd2..1579556f 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -65,6 +65,7 @@ class _PgTreeRow extends material.StatelessWidget { this.iconColor, this.trailing, this.onTap, + this.expanded, this.verticalPadding = 3, required this.textStyle, this.connection, @@ -85,6 +86,9 @@ class _PgTreeRow extends material.StatelessWidget { final material.Color? iconColor; final material.Widget? trailing; final void Function()? onTap; + + /// When non-null, row is an expand control ([Semantics.button] + expanded). + final bool? expanded; final double verticalPadding; final material.TextStyle textStyle; final ConnectionRow? connection; @@ -141,8 +145,16 @@ class _PgTreeRow extends material.StatelessWidget { ), ), ); - if (connection == null) return row; - return ContextMenu( + if (connection == null) { + return expanded == null + ? row + : material.Semantics( + button: true, + expanded: expanded, + child: row, + ); + } + final menu = ContextMenu( items: [ if (onContextRefresh != null) MenuButton( @@ -194,6 +206,12 @@ class _PgTreeRow extends material.StatelessWidget { ], child: row, ); + if (expanded == null) return menu; + return material.Semantics( + button: true, + expanded: expanded, + child: menu, + ); } } @@ -229,6 +247,7 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { color: theme.colorScheme.foreground, ), verticalPadding: 4, + expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), connection: widget.connection, onContextRefresh: widget.onRefreshDatabases, @@ -356,6 +375,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { color: theme.colorScheme.foreground, ), verticalPadding: 4, + expanded: _expanded, onTap: _toggle, connection: widget.connection, onContextRefresh: _loadSchemas, @@ -406,6 +426,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { ) else if (_error != null) TreeLoadError( + title: 'Could not load schemas', message: _error!, onRetry: _loadSchemas, ), @@ -550,6 +571,7 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { fontSize: 11, color: theme.colorScheme.mutedForeground, ), + expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), connection: widget.connection, onContextRefresh: widget.onRefreshSchemas, @@ -706,6 +728,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { fontSize: 12, color: theme.colorScheme.foreground, ), + expanded: _expanded, onTap: _toggle, connection: widget.connection, onContextRefresh: _loadObjects, @@ -736,6 +759,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { ) else if (_error != null) TreeLoadError( + title: 'Could not load objects', message: _error!, onRetry: _loadObjects, ), @@ -1024,6 +1048,7 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { fontSize: 11, color: theme.colorScheme.mutedForeground, ), + expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), connection: widget.connection, onContextRefresh: widget.onRefresh, diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index f592e3c6..c6ecc336 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -151,19 +151,25 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { children: [ material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.treeExpand), - curve: context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), @@ -241,6 +247,7 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { ), if (_error != null) TreeLoadError( + title: 'Could not load databases', message: _error!, padding: const material.EdgeInsets.only( left: 28, diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index cdb0aa4e..fb0f650b 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -168,19 +168,25 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { // Expand/collapse arrow material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.treeExpand), - curve: context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), @@ -260,6 +266,7 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { ), if (_error != null) TreeLoadError( + title: 'Could not load Redis info', message: _error!, padding: const material.EdgeInsets.only( left: 28, diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 98229949..8d981b0b 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -247,41 +247,47 @@ class _FolderTileState extends State<_FolderTile> { children: [ material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, vertical: 6), - 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.sidebarExpand, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 6), + 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.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), - ), - const Gap(2), - material.Icon(QueryaIcons.folder, - size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary), - const Gap(8), - material.Expanded( - child: material.Text( - widget.name, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, + const Gap(2), + material.Icon(QueryaIcons.folder, + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary), + const Gap(8), + material.Expanded( + child: material.Text( + widget.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 0563f513..1b5dcb01 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -163,19 +163,25 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { children: [ material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.treeExpand), - curve: context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), @@ -253,6 +259,7 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { ), if (_error != null) TreeLoadError( + title: 'Could not load objects', message: _error!, padding: const material.EdgeInsets.only( left: 28, @@ -378,6 +385,7 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { fontSize: 11, color: theme.colorScheme.mutedForeground, ), + expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), connection: widget.connection, onContextRefresh: widget.onRefresh, diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index 38a34464..a32fd6db 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -75,85 +75,77 @@ class _DriverManagerDialogContent extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final drivers = _buildDriverList(); - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 520, minWidth: 400, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Driver Manager').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Built-in Dart drivers and installed sandboxed extension drivers. ' - 'Add a server under Connection → New Database Connection.', - ).muted().small(), - ], - ), - ), - material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 12), - child: material.Container( - decoration: material.BoxDecoration( - color: theme.muted.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(10), - border: material.Border.all( - color: theme.border.withValues(alpha: 0.3)), - ), - child: material.ListView.separated( - shrinkWrap: true, - padding: const material.EdgeInsets.symmetric(vertical: 8), - itemCount: drivers.length, - separatorBuilder: (_, __) => material.Divider( - height: 1, - color: theme.border.withValues(alpha: 0.3), - ), - itemBuilder: (context, index) { - final info = drivers[index]; - return _DriverRow(info: info, theme: theme); - }, - ), - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Driver Manager').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Built-in Dart drivers and installed sandboxed extension drivers. ' + 'Add a server under Connection → New Database Connection.', + ).muted().small(), + ], ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + child: material.Container( decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3)), + color: theme.muted.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: theme.border.withValues(alpha: 0.3)), + ), + child: material.ListView.separated( + shrinkWrap: true, + padding: const material.EdgeInsets.symmetric(vertical: 8), + itemCount: drivers.length, + separatorBuilder: (_, __) => material.Divider( + height: 1, + color: theme.border.withValues(alpha: 0.3), ), + itemBuilder: (context, index) { + final info = drivers[index]; + return _DriverRow(info: info, theme: theme); + }, ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - PrimaryButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), ), ), - ], - ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], ), ); } diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index dc26a23f..b64f2470 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -105,26 +105,20 @@ class _NewConnectionDialogContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(context); final dialogH = WindowLayout.newConnectionDialogHeight(context); final headerPadH = dialogMaxW < 420 ? 16.0 : 24.0; final stackFilters = dialogMaxW < 520; - return material.Container( + return material.SizedBox( width: dialogMaxW, - constraints: material.BoxConstraints( - maxWidth: dialogMaxW, - maxHeight: dialogH, - minHeight: math.min(320.0, dialogH), - ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), + child: QueryaDialogCard( + constraints: material.BoxConstraints( + maxWidth: dialogMaxW, + maxHeight: dialogH, + minHeight: math.min(320.0, dialogH), + ), + borderColor: theme.muted, child: material.SizedBox( height: dialogH, child: material.Column( @@ -403,8 +397,7 @@ class _DbTypeCard extends material.StatelessWidget { final highlight = t.muted.withValues(alpha: 0.4); return QueryaHoverSurface( borderRadius: material.BorderRadius.circular(10), - padding: - const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8), + padding: const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8), idleColor: selected ? highlight : t.muted.withValues(alpha: 0.12), hoveredColor: highlight, border: material.Border.all( diff --git a/lib/features/connections/new_connection_url_dialog.dart b/lib/features/connections/new_connection_url_dialog.dart index 6c73fe42..b4c67678 100644 --- a/lib/features/connections/new_connection_url_dialog.dart +++ b/lib/features/connections/new_connection_url_dialog.dart @@ -6,7 +6,8 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows a dialog to create a new database connection from a URI. /// Returns the ConnectionRow or null if cancelled. -Future showNewConnectionUrlDialog(material.BuildContext context) { +Future showNewConnectionUrlDialog( + material.BuildContext context) { return showAppDialog( context: context, builder: (context) => material.Dialog( @@ -48,108 +49,101 @@ class _NewConnectionUrlDialogContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 580, minWidth: 420, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('New connection from URL').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Create a connection by pasting a database URI (e.g. postgresql://user:pass@host:5432/db).', - ).muted().small(), - const material.SizedBox(height: 16), - material.Container( - decoration: material.BoxDecoration( - color: theme.muted.withValues(alpha: 0.2), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('New connection from URL').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Create a connection by pasting a database URI (e.g. postgresql://user:pass@host:5432/db).', + ).muted().small(), + const material.SizedBox(height: 16), + material.Container( + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _validationError != null + ? theme.destructive.withValues(alpha: 0.8) + : theme.border.withValues(alpha: 0.4), + ), + ), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: material.Row( + children: [ + material.Icon( + material.Icons.link_rounded, + size: 20, color: _validationError != null - ? theme.destructive.withValues(alpha: 0.8) - : theme.border.withValues(alpha: 0.4), + ? theme.destructive + : theme.mutedForeground, ), - ), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - child: material.Row( - children: [ - material.Icon( - material.Icons.link_rounded, - size: 20, - color: _validationError != null - ? theme.destructive - : theme.mutedForeground, + const material.SizedBox(width: 10), + material.Expanded( + child: TextField( + controller: _urlController, + placeholder: + const Text('database://user:pass@host:port/db'), + onSubmitted: (_) => _validateAndSubmit(), + onChanged: (_) { + if (_validationError != null) { + setState(() => _validationError = null); + } + }, ), - const material.SizedBox(width: 10), - material.Expanded( - child: TextField( - controller: _urlController, - placeholder: const Text('database://user:pass@host:port/db'), - onSubmitted: (_) => _validateAndSubmit(), - onChanged: (_) { - if (_validationError != null) { - setState(() => _validationError = null); - } - }, - ), - ), - ], - ), + ), + ], ), - if (_validationError != null) ...[ - const material.SizedBox(height: 8), - Text( - _validationError!, - style: material.TextStyle(color: theme.destructive), - ).small(), - ], + ), + if (_validationError != null) ...[ + const material.SizedBox(height: 8), + Text( + _validationError!, + style: material.TextStyle(color: theme.destructive), + ).small(), ], + ], + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3)), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), ), - ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const material.SizedBox(width: 12), - PrimaryButton( - onPressed: _validateAndSubmit, - child: const Text('Create'), - ), - ], - ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _validateAndSubmit, + child: const Text('Create'), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/connections/new_folder_dialog.dart b/lib/features/connections/new_folder_dialog.dart index f4c6a851..37c37539 100644 --- a/lib/features/connections/new_folder_dialog.dart +++ b/lib/features/connections/new_folder_dialog.dart @@ -37,93 +37,85 @@ class _NewFolderDialogContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 440, minWidth: 360, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('New folder').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Enter a name for the new folder in the browser tree.', - ).muted().small(), - const material.SizedBox(height: 16), - material.Container( - decoration: material.BoxDecoration( - color: theme.muted.withValues(alpha: 0.2), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: theme.border.withValues(alpha: 0.4)), - ), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - child: material.Row( - children: [ - material.Icon( - material.Icons.folder_rounded, - size: 20, - color: theme.mutedForeground, - ), - const material.SizedBox(width: 10), - material.Expanded( - child: TextField( - controller: _nameController, - placeholder: const Text('Folder name'), - onChanged: (_) => setState(() {}), - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('New folder').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Enter a name for the new folder in the browser tree.', + ).muted().small(), + const material.SizedBox(height: 16), + material.Container( + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: theme.border.withValues(alpha: 0.4)), + ), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: material.Row( + children: [ + material.Icon( + material.Icons.folder_rounded, + size: 20, + color: theme.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: TextField( + controller: _nameController, + placeholder: const Text('Folder name'), + onChanged: (_) => setState(() {}), ), - ], - ), + ), + ], ), - ], + ), + ], + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3)), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), ), - ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const material.SizedBox(width: 12), - PrimaryButton( - onPressed: _name.isEmpty - ? null - : () => material.Navigator.of(context).pop(_name), - child: const Text('Create'), - ), - ], - ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _name.isEmpty + ? null + : () => material.Navigator.of(context).pop(_name), + child: const Text('Create'), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index d87455d1..a2f0a275 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -158,86 +158,77 @@ class _ExtensionManagerContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final onPopover = theme.popoverForeground; return material.DefaultTextStyle( style: material.TextStyle(color: onPopover), child: material.IconTheme( data: material.IconThemeData(color: onPopover), - child: material.Container( + child: QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 800, minWidth: 600, maxHeight: 700, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.border), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.spaceBetween, - crossAxisAlignment: material.CrossAxisAlignment.center, - children: [ - material.Expanded( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Extensions') - .large() - .semiBold() - .foreground(), - const material.SizedBox(height: 6), - const Text( - 'Manage local and marketplace extensions') - .muted() - .small(), - ], - ), - ), - const material.SizedBox(width: 16), - PrimaryButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), + borderColor: theme.border, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Extensions') + .large() + .semiBold() + .foreground(), + const material.SizedBox(height: 6), + const Text('Manage local and marketplace extensions') + .muted() + .small(), + ], ), - ], - ), + ), + const material.SizedBox(width: 16), + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], ), - material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 24.0, vertical: 8.0), - child: QueryaTabStrip( - labels: [ - 'Installed (${_installed.length})', - 'Marketplace', - 'Updates', - ], - selectedIndex: _tabIndex, - onSelected: (index) => setState(() => _tabIndex = index), - ), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 24.0, vertical: 8.0), + child: QueryaTabStrip( + labels: [ + 'Installed (${_installed.length})', + 'Marketplace', + 'Updates', + ], + selectedIndex: _tabIndex, + onSelected: (index) => setState(() => _tabIndex = index), ), - material.Divider(height: 1, color: theme.border), - material.Expanded( - child: QueryaCrossFadeStack( - index: _tabIndex, - children: [ - _buildInstalledTab(), - _buildMarketplaceTab(), - _buildUpdatesTab(), - ], - ), + ), + material.Divider(height: 1, color: theme.border), + material.Expanded( + child: QueryaCrossFadeStack( + index: _tabIndex, + children: [ + _buildInstalledTab(), + _buildMarketplaceTab(), + _buildUpdatesTab(), + ], ), - ], - ), + ), + ], ), ), ), diff --git a/lib/features/help/about_dialog.dart b/lib/features/help/about_dialog.dart index 11eef0a7..8735e213 100644 --- a/lib/features/help/about_dialog.dart +++ b/lib/features/help/about_dialog.dart @@ -31,84 +31,76 @@ class _AboutDialogContentState extends material.State<_AboutDialogContent> { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final wb = context.workbench; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 420, minWidth: 320, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 28, 24, 8), - child: material.Column( - children: [ - material.Icon( - material.Icons.search_rounded, - size: 48, - color: wb.accent, - ), - const material.SizedBox(height: 16), - const Text('Querya').large().semiBold(), - const material.SizedBox(height: 8), - FutureBuilder( - future: _packageInfo, - builder: (context, snapshot) { - final version = snapshot.data?.version ?? '…'; - return Text('Version $version').muted().small(); - }, - ), - const material.SizedBox(height: 16), - const Text( - 'A lightweight desktop SQL/NoSQL client.', - ).muted().small(), - const material.SizedBox(height: 12), - const Text( - 'Licensed under the MIT License.', - ).small(), - const material.SizedBox(height: 16), - GhostButton( - onPressed: () => launchRepositoryUrl(), - child: const Text('View repository'), - ), - ], - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 28, 24, 8), + child: material.Column( + children: [ + material.Icon( + material.Icons.search_rounded, + size: 48, + color: wb.accent, + ), + const material.SizedBox(height: 16), + const Text('Querya').large().semiBold(), + const material.SizedBox(height: 8), + FutureBuilder( + future: _packageInfo, + builder: (context, snapshot) { + final version = snapshot.data?.version ?? '…'; + return Text('Version $version').muted().small(); + }, + ), + const material.SizedBox(height: 16), + const Text( + 'A lightweight desktop SQL/NoSQL client.', + ).muted().small(), + const material.SizedBox(height: 12), + const Text( + 'Licensed under the MIT License.', + ).small(), + const material.SizedBox(height: 16), + GhostButton( + onPressed: () => launchRepositoryUrl(), + child: const Text('View repository'), + ), + ], ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3), - ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3), ), ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - PrimaryButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ), ), - ], - ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], ), ); } diff --git a/lib/features/mongodb/mongo_database_dialog.dart b/lib/features/mongodb/mongo_database_dialog.dart index af10e3d9..2bb39f8e 100644 --- a/lib/features/mongodb/mongo_database_dialog.dart +++ b/lib/features/mongodb/mongo_database_dialog.dart @@ -51,77 +51,69 @@ class _CreateMongoDBDialogContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints(context, maxWidth: 500), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Row( - children: [ - material.Icon(material.Icons.storage_rounded, - size: 24, color: theme.primary), - const Gap(12), - const Text('Create Database').large().semiBold(), - ], - ), - const Gap(8), - const Text('Enter the name for the new MongoDB database.') - .muted() - .small(), - ], - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + children: [ + material.Icon(material.Icons.storage_rounded, + size: 24, color: theme.primary), + const Gap(12), + const Text('Create Database').large().semiBold(), + ], + ), + const Gap(8), + const Text('Enter the name for the new MongoDB database.') + .muted() + .small(), + ], ), - const material.Divider(height: 1), - material.Padding( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Database Name').small().semiBold(), - const Gap(8), - TextField( - controller: _nameController, - placeholder: const Text('mydb'), - ), - ], - ), + ), + const material.Divider(height: 1), + material.Padding( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Database Name').small().semiBold(), + const Gap(8), + TextField( + controller: _nameController, + placeholder: const Text('mydb'), + ), + ], ), - const material.Divider(height: 1), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: _formValid ? _save : null, - child: const Text('Create'), - ), - ], - ), + ), + const material.Divider(height: 1), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: _formValid ? _save : null, + child: const Text('Create'), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart index e1c09ef8..d18b62bb 100644 --- a/lib/features/mysql/mysql_sql_editor_dialog.dart +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -73,102 +73,93 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; return material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: material.Container( + child: QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 720, minWidth: 480, maxHeight: 520, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('SQL query').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Table browse uses SELECT with LIMIT/OFFSET. ' - 'Edit or write your own SELECT. Reset restores the browse query. ' - 'Run reloads the grid; unchanged data looks the same.', - ).muted().xSmall(), - ], - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('SQL query').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Table browse uses SELECT with LIMIT/OFFSET. ' + 'Edit or write your own SELECT. Reset restores the browse query. ' + 'Run reloads the grid; unchanged data looks the same.', + ).muted().xSmall(), + ], ), - material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 24), - child: material.SizedBox( - height: 280, - child: material.Container( - decoration: - SqlEditorChrome.inlineFieldDecorationFromContext( - context, - ), - child: QueryaCodeEditor( - controller: _controller, - language: QueryaCodeLanguage.sql, - fontSize: 12, - variant: QueryaCodeEditorVariant.material, - textAlignVertical: material.TextAlignVertical.top, - hintText: 'SELECT …', - contentPadding: const material.EdgeInsets.all(12), - ), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 24), + child: material.SizedBox( + height: 280, + child: material.Container( + decoration: SqlEditorChrome.inlineFieldDecorationFromContext( + context, ), - ), - ), - if (_error != null) - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 0), - child: material.Text( - _error!, - style: material.TextStyle( - color: theme.destructive, fontSize: 12), + child: QueryaCodeEditor( + controller: _controller, + language: QueryaCodeLanguage.sql, + fontSize: 12, + variant: QueryaCodeEditorVariant.material, + textAlignVertical: material.TextAlignVertical.top, + hintText: 'SELECT …', + contentPadding: const material.EdgeInsets.all(12), ), ), + ), + ), + if (_error != null) material.Padding( - padding: const material.EdgeInsets.all(20), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - OutlineButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(8), - OutlineButton( - onPressed: () { - setState(() { - _error = null; - _controller.text = widget.browseSql; - }); - }, - child: const Text('Reset'), - ), - const Gap(8), - PrimaryButton( - onPressed: _submit, - child: const Text('Run'), - ), - ], + padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 0), + child: material.Text( + _error!, + style: material.TextStyle( + color: theme.destructive, fontSize: 12), ), ), - ], - ), + material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(8), + OutlineButton( + onPressed: () { + setState(() { + _error = null; + _controller.text = widget.browseSql; + }); + }, + child: const Text('Reset'), + ), + const Gap(8), + PrimaryButton( + onPressed: _submit, + child: const Text('Run'), + ), + ], + ), + ), + ], ), ), ); diff --git a/lib/features/postgresql/postgres_sql_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index 1f28fefb..49177e71 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -101,102 +101,93 @@ class _PostgresSqlEditorDialogState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; return material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: material.Container( + child: QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 720, minWidth: 480, maxHeight: 520, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('SQL query').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Table browse uses SELECT with LIMIT/OFFSET. ' - 'Edit it or write your own SELECT. Reset restores the table browse query. ' - 'Run reloads the grid from the database; if rows are unchanged, the view will look the same.', - ).muted().xSmall(), - ], - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('SQL query').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Table browse uses SELECT with LIMIT/OFFSET. ' + 'Edit it or write your own SELECT. Reset restores the table browse query. ' + 'Run reloads the grid from the database; if rows are unchanged, the view will look the same.', + ).muted().xSmall(), + ], ), - material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 24), - child: material.SizedBox( - height: 280, - child: material.Container( - decoration: - SqlEditorChrome.inlineFieldDecorationFromContext( - context, - ), - child: QueryaCodeEditor( - controller: _controller, - language: QueryaCodeLanguage.sql, - fontSize: 12, - variant: QueryaCodeEditorVariant.material, - textAlignVertical: material.TextAlignVertical.top, - hintText: 'SELECT …', - contentPadding: const material.EdgeInsets.all(12), - ), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 24), + child: material.SizedBox( + height: 280, + child: material.Container( + decoration: SqlEditorChrome.inlineFieldDecorationFromContext( + context, ), - ), - ), - if (_error != null) - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 0), - child: material.Text( - _error!, - style: material.TextStyle( - color: theme.destructive, fontSize: 12), + child: QueryaCodeEditor( + controller: _controller, + language: QueryaCodeLanguage.sql, + fontSize: 12, + variant: QueryaCodeEditorVariant.material, + textAlignVertical: material.TextAlignVertical.top, + hintText: 'SELECT …', + contentPadding: const material.EdgeInsets.all(12), ), ), + ), + ), + if (_error != null) material.Padding( - padding: const material.EdgeInsets.all(20), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - OutlineButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(8), - OutlineButton( - onPressed: () { - setState(() { - _error = null; - _controller.text = widget.browseSql; - }); - }, - child: const Text('Reset'), - ), - const Gap(8), - PrimaryButton( - onPressed: _submit, - child: const Text('Run'), - ), - ], + padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 0), + child: material.Text( + _error!, + style: material.TextStyle( + color: theme.destructive, fontSize: 12), ), ), - ], - ), + material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(8), + OutlineButton( + onPressed: () { + setState(() { + _error = null; + _controller.text = widget.browseSql; + }); + }, + child: const Text('Reset'), + ), + const Gap(8), + PrimaryButton( + onPressed: _submit, + child: const Text('Run'), + ), + ], + ), + ), + ], ), ), ); diff --git a/lib/features/postgresql/postgres_table_privileges_dialog.dart b/lib/features/postgresql/postgres_table_privileges_dialog.dart index c5234604..fc3a2f10 100644 --- a/lib/features/postgresql/postgres_table_privileges_dialog.dart +++ b/lib/features/postgresql/postgres_table_privileges_dialog.dart @@ -77,7 +77,6 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final mq = material.MediaQuery.sizeOf(context); final hInset = WindowLayout.dialogVerticalInset(mq.height) * 2; final wInset = WindowLayout.dialogHorizontalInset(mq.width) * 2; @@ -103,66 +102,59 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { child: material.SizedBox( width: dialogWidth, height: dialogHeight, - child: material.DecoratedBox( - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(20, 16, 20, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('Table privileges').large().semiBold(), - const material.SizedBox(height: 4), - material.Text( - '${widget.schema}.${widget.tableName}', - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: theme.mutedForeground, - ), - maxLines: 2, - overflow: material.TextOverflow.ellipsis, + child: QueryaDialogCard( + borderColor: theme.muted, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(20, 16, 20, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Table privileges').large().semiBold(), + const material.SizedBox(height: 4), + material.Text( + '${widget.schema}.${widget.tableName}', + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: theme.mutedForeground, ), - const material.SizedBox(height: 4), - const Text( - 'From information_schema.role_table_grants (read-only).', - ).muted().xSmall(), - ], - ), + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ), + const material.SizedBox(height: 4), + const Text( + 'From information_schema.role_table_grants (read-only).', + ).muted().xSmall(), + ], ), - const material.Divider(height: 1), - material.Expanded(child: _buildListArea(theme)), - const material.Divider(height: 1), - material.Padding( - padding: const material.EdgeInsets.all(12), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ + ), + const material.Divider(height: 1), + material.Expanded(child: _buildListArea(theme)), + const material.Divider(height: 1), + material.Padding( + padding: const material.EdgeInsets.all(12), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + if (!_loading && _error == null) ...[ + const Gap(8), OutlineButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), + onPressed: _load, + child: const Text('Reload'), ), - if (!_loading && _error == null) ...[ - const Gap(8), - OutlineButton( - onPressed: _load, - child: const Text('Reload'), - ), - ], ], - ), + ], ), - ], - ), + ), + ], ), ), ), diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 7302877d..6d2554c6 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -96,215 +96,205 @@ class _PreferencesDialogContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final onPopover = theme.popoverForeground; return material.DefaultTextStyle( style: material.TextStyle(color: onPopover), child: material.IconTheme( data: material.IconThemeData(color: onPopover), - child: material.Container( + child: QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.preferencesDialogMaxWidth, minWidth: WindowLayout.preferencesDialogMinWidth, maxHeight: WindowLayout.preferencesDialogMaxHeight, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.border), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Preferences').large().semiBold().foreground(), - const material.SizedBox(height: 6), - const PreferencesHint( - 'Changes apply immediately. SQL timeouts are global for all connections of that type.', - ), - ], - ), + borderColor: theme.border, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Preferences').large().semiBold().foreground(), + const material.SizedBox(height: 6), + const PreferencesHint( + 'Changes apply immediately. SQL timeouts are global for all connections of that type.', + ), + ], ), - material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 8), - child: _loading - ? const material.Center( - child: material.Padding( - padding: material.EdgeInsets.all(24), - child: material.CircularProgressIndicator(), + ), + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 8), + child: _loading + ? const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(24), + child: material.CircularProgressIndicator(), + ), + ) + : material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('General') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + PreferencesCheckboxRow( + value: _checkUpdatesOnStartup, + title: const Text( + 'Automatically check for updates on startup', + ).small(), + subtitle: const Text( + 'Queries GitHub Releases silently when Querya starts.', + ).muted().xSmall(), + onChanged: (v) { + unawaited(_setCheckUpdatesOnStartup(v)); + }, ), - ) - : material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - const Text('General') - .semiBold() - .small() - .foreground(), - const material.SizedBox(height: 8), - PreferencesCheckboxRow( - value: _checkUpdatesOnStartup, - title: const Text( - 'Automatically check for updates on startup', - ).small(), - subtitle: const Text( - 'Queries GitHub Releases silently when Querya starts.', - ).muted().xSmall(), - onChanged: (v) { - unawaited(_setCheckUpdatesOnStartup(v)); - }, - ), - const material.SizedBox(height: 24), - const PreferencesAppearanceSection(), - const material.SizedBox(height: 24), - const PreferencesExtensionsSection(), - const material.SizedBox(height: 24), - const Text('SQL — PostgreSQL') - .semiBold() - .small() - .foreground(), - const material.SizedBox(height: 8), - PreferencesFieldRow( - label: 'Statement timeout', - control: SqlStatementTimeoutDropdown( - value: _pgTimeout, - expandToParent: true, - onChanged: (v) => unawaited(_setPg(v)), - ), - ), - const material.SizedBox(height: 24), - const Text('SQL — MySQL / MariaDB') - .semiBold() - .small() - .foreground(), - const material.SizedBox(height: 8), - PreferencesFieldRow( - label: 'Statement timeout', - control: SqlStatementTimeoutDropdown( - value: _mysqlTimeout, - expandToParent: true, - onChanged: (v) => unawaited(_setMysql(v)), - ), + const material.SizedBox(height: 24), + const PreferencesAppearanceSection(), + const material.SizedBox(height: 24), + const PreferencesExtensionsSection(), + const material.SizedBox(height: 24), + const Text('SQL — PostgreSQL') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + PreferencesFieldRow( + label: 'Statement timeout', + control: SqlStatementTimeoutDropdown( + value: _pgTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setPg(v)), ), - const material.SizedBox(height: 24), - const Text('SQL editor') - .semiBold() - .small() - .foreground(), - const material.SizedBox(height: 8), - PreferencesFieldRow( - label: 'Max rows in results', - control: PreferencesDropdownMenu( - value: _maxRows, - onSelected: (v) { - if (v != null) unawaited(_setMaxRows(v)); - }, - entries: [ - for (final n in kSqlResultMaxRowsPresets) - material.DropdownMenuEntry( - value: n, - label: '$n', - ), - ], - ), - ), - const material.SizedBox(height: 12), - PreferencesFieldRow( - label: 'Query history limit', - hint: - 'Per connection and database; oldest queries are dropped.', - control: PreferencesDropdownMenu( - value: _historyMax, - onSelected: (v) { - if (v != null) { - unawaited(_setHistoryMax(v)); - } - }, - entries: [ - for (final n - in kSqlHistoryMaxEntriesPresets) - material.DropdownMenuEntry( - value: n, - label: '$n entries', - ), - ], - ), + ), + const material.SizedBox(height: 24), + const Text('SQL — MySQL / MariaDB') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + PreferencesFieldRow( + label: 'Statement timeout', + control: SqlStatementTimeoutDropdown( + value: _mysqlTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setMysql(v)), ), - const material.SizedBox(height: 12), - PreferencesFieldRow( - label: 'Font size', - control: PreferencesDropdownMenu( - value: _fontSize, - onSelected: (v) { - if (v != null) unawaited(_setFont(v)); - }, - entries: const [ - material.DropdownMenuEntry( - value: 11.0, - label: '11 pt', - ), - material.DropdownMenuEntry( - value: 12.0, - label: '12 pt', - ), - material.DropdownMenuEntry( - value: 13.0, - label: '13 pt', - ), - material.DropdownMenuEntry( - value: 14.0, - label: '14 pt', - ), + ), + const material.SizedBox(height: 24), + const Text('SQL editor') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + PreferencesFieldRow( + label: 'Max rows in results', + control: PreferencesDropdownMenu( + value: _maxRows, + onSelected: (v) { + if (v != null) unawaited(_setMaxRows(v)); + }, + entries: [ + for (final n in kSqlResultMaxRowsPresets) material.DropdownMenuEntry( - value: 16.0, - label: '16 pt', + value: n, + label: '$n', ), + ], + ), + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Query history limit', + hint: + 'Per connection and database; oldest queries are dropped.', + control: PreferencesDropdownMenu( + value: _historyMax, + onSelected: (v) { + if (v != null) { + unawaited(_setHistoryMax(v)); + } + }, + entries: [ + for (final n in kSqlHistoryMaxEntriesPresets) material.DropdownMenuEntry( - value: 18.0, - label: '18 pt', + value: n, + label: '$n entries', ), - ], - ), + ], ), - const material.SizedBox(height: 16), - const PreferencesHint( - 'Preferences are stored locally in SQLite (non-secret keys only).', + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Font size', + control: PreferencesDropdownMenu( + value: _fontSize, + onSelected: (v) { + if (v != null) unawaited(_setFont(v)); + }, + entries: const [ + material.DropdownMenuEntry( + value: 11.0, + label: '11 pt', + ), + material.DropdownMenuEntry( + value: 12.0, + label: '12 pt', + ), + material.DropdownMenuEntry( + value: 13.0, + label: '13 pt', + ), + material.DropdownMenuEntry( + value: 14.0, + label: '14 pt', + ), + material.DropdownMenuEntry( + value: 16.0, + label: '16 pt', + ), + material.DropdownMenuEntry( + value: 18.0, + label: '18 pt', + ), + ], ), - ], - ), + ), + const material.SizedBox(height: 16), + const PreferencesHint( + 'Preferences are stored locally in SQLite (non-secret keys only).', + ), + ], + ), + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3)), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), ), - ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - PrimaryButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index c9582d94..6600fb91 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -228,89 +228,81 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final wb = context.workbench; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 520, minWidth: 360, maxHeight: 640, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Row( - children: [ - material.Icon( - material.Icons.system_update_alt_rounded, - color: wb.accent, - ), - const material.SizedBox(width: 10), - const Text('Software Update').large().semiBold(), - ], - ), - const material.SizedBox(height: 8), - Text(_subtitle()).muted().small(), - ], - ), - ), - material.Flexible( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, - vertical: 8, - ), - child: material.AnimatedSwitcher( - duration: context.motionDuration(QueryaMotion.standard), - switchInCurve: context.motionCurve(QueryaMotion.enter), - switchOutCurve: context.motionCurve(QueryaMotion.exit), - layoutBuilder: (currentChild, previousChildren) { - return material.Stack( - alignment: material.Alignment.topCenter, - children: [ - ...previousChildren, - if (currentChild != null) currentChild, - ], - ); - }, - child: material.KeyedSubtree( - key: material.ValueKey(_phase), - child: _body(context), - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + material.Icon( + material.Icons.system_update_alt_rounded, + color: wb.accent, + ), + const material.SizedBox(width: 10), + const Text('Software Update').large().semiBold(), + ], ), - ), + const material.SizedBox(height: 8), + Text(_subtitle()).muted().small(), + ], ), - material.Container( + ), + material.Flexible( + child: material.SingleChildScrollView( padding: const material.EdgeInsets.symmetric( horizontal: 24, - vertical: 16, + vertical: 8, + ), + child: material.AnimatedSwitcher( + duration: context.motionDuration(QueryaMotion.standard), + switchInCurve: context.motionCurve(QueryaMotion.enter), + switchOutCurve: context.motionCurve(QueryaMotion.exit), + layoutBuilder: (currentChild, previousChildren) { + return material.Stack( + alignment: material.Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: material.KeyedSubtree( + key: material.ValueKey(_phase), + child: _body(context), + ), ), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3), - ), + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3), ), ), - child: _actions(context), ), - ], - ), + child: _actions(context), + ), + ], ), ); } diff --git a/lib/shared/widgets/querya_dialog_card.dart b/lib/shared/widgets/querya_dialog_card.dart new file mode 100644 index 00000000..4a895715 --- /dev/null +++ b/lib/shared/widgets/querya_dialog_card.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Dialog shell card: [Material] ink host + popover fill (avoids ListTile asserts +/// under opaque [DecoratedBox] on Flutter 3.44+). +class QueryaDialogCard extends material.StatelessWidget { + const QueryaDialogCard({ + super.key, + required this.child, + this.constraints, + this.borderColor, + }); + + final material.Widget child; + final material.BoxConstraints? constraints; + final material.Color? borderColor; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + return material.Material( + color: theme.popover, + elevation: 0, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(radius), + side: material.BorderSide(color: borderColor ?? theme.border), + ), + clipBehavior: material.Clip.antiAlias, + child: constraints == null + ? child + : material.ConstrainedBox( + constraints: constraints!, + child: child, + ), + ); + } +} diff --git a/lib/shared/widgets/tree_load_error.dart b/lib/shared/widgets/tree_load_error.dart index 7bc6afdb..aabaee49 100644 --- a/lib/shared/widgets/tree_load_error.dart +++ b/lib/shared/widgets/tree_load_error.dart @@ -4,10 +4,13 @@ import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Inline error block for connection tree lazy-load failures. +/// +/// Always shows the error icon + title row by default (Mongo dialect); +/// pass [showTitleRow]: false only for ultra-compact one-liners. class TreeLoadError extends material.StatelessWidget { const TreeLoadError({ super.key, - this.title, + this.title = 'Could not load', required this.message, this.onRetry, this.retryLabel = 'Retry', @@ -17,10 +20,10 @@ class TreeLoadError extends material.StatelessWidget { bottom: 8, ), this.detailFontSize = 11, - this.showTitleRow = false, + this.showTitleRow = true, }); - final String? title; + final String title; final String message; final VoidCallback? onRetry; final String retryLabel; @@ -40,7 +43,7 @@ class TreeLoadError extends material.StatelessWidget { crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - if (showTitleRow && title != null) + if (showTitleRow) material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ @@ -52,7 +55,7 @@ class TreeLoadError extends material.StatelessWidget { const Gap(6), material.Expanded( child: material.Text( - title!, + title, maxLines: 2, overflow: material.TextOverflow.ellipsis, style: material.TextStyle( @@ -63,7 +66,7 @@ class TreeLoadError extends material.StatelessWidget { ), ], ), - if (showTitleRow && title != null) const Gap(6), + if (showTitleRow) const Gap(6), material.SelectableText( message, style: material.TextStyle( diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 4c9f5bbe..49ae9541 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -11,6 +11,7 @@ library; export 'app_dialog.dart'; export 'app_toast.dart'; export 'export_menu_button.dart'; +export 'querya_dialog_card.dart'; export 'querya_tab_strip.dart'; export 'querya_dropdown.dart' show diff --git a/test/shared/widgets/tree_load_error_test.dart b/test/shared/widgets/tree_load_error_test.dart index 3aa281f8..4e79bf53 100644 --- a/test/shared/widgets/tree_load_error_test.dart +++ b/test/shared/widgets/tree_load_error_test.dart @@ -17,8 +17,10 @@ void main() { ), ); + expect(find.text('Could not load'), findsOneWidget); expect(find.text('connection refused'), findsOneWidget); expect(find.text('Retry'), findsOneWidget); + expect(find.byIcon(material.Icons.error_outline_rounded), findsOneWidget); await tester.tap(find.text('Retry')); await tester.pump(); @@ -26,18 +28,32 @@ void main() { expect(retried, isTrue); }); - testWidgets('TreeLoadError title row uses error icon', (tester) async { + testWidgets('TreeLoadError title row uses custom title', (tester) async { await tester.pumpWidget( queryaThemeTestShell( child: const TreeLoadError( - title: 'Could not load', + title: 'Could not load databases', message: 'timeout', - showTitleRow: true, ), ), ); - expect(find.text('Could not load'), findsOneWidget); + expect(find.text('Could not load databases'), findsOneWidget); expect(find.byIcon(material.Icons.error_outline_rounded), findsOneWidget); }); + + testWidgets('TreeLoadError can hide title row', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const TreeLoadError( + message: 'timeout', + showTitleRow: false, + ), + ), + ); + + expect(find.text('Could not load'), findsNothing); + expect(find.byIcon(material.Icons.error_outline_rounded), findsNothing); + expect(find.text('timeout'), findsOneWidget); + }); } From 45354e94160e8373efa382d9f28b298df2e3d5e9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 22:10:14 +0300 Subject: [PATCH 27/44] fix(ui): keep ambient text style under QueryaDialogCard Material Material injects ThemeData.textTheme and bloated Extension Manager chrome enough to overflow the Marketplace tab; re-apply the ambient DefaultTextStyle/IconTheme after Material. --- lib/shared/widgets/querya_dialog_card.dart | 36 ++++++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/lib/shared/widgets/querya_dialog_card.dart b/lib/shared/widgets/querya_dialog_card.dart index 4a895715..29f71fd8 100644 --- a/lib/shared/widgets/querya_dialog_card.dart +++ b/lib/shared/widgets/querya_dialog_card.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart' as material; import 'package:shadcn_flutter/shadcn_flutter.dart'; -/// Dialog shell card: [Material] ink host + popover fill (avoids ListTile asserts -/// under opaque [DecoratedBox] on Flutter 3.44+). +/// Dialog shell: [Material] popover fill (ListTile ink host) under the same +/// [Container] constraints as the pre-migration shell. +/// +/// Re-applies the ambient [DefaultTextStyle] / [IconTheme] after [Material], +/// which would otherwise inject [ThemeData.textTheme] and bloat dense dialog +/// chrome (Extension Manager overflow). class QueryaDialogCard extends material.StatelessWidget { const QueryaDialogCard({ super.key, @@ -19,20 +23,32 @@ class QueryaDialogCard extends material.StatelessWidget { material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; - return material.Material( + final borderRadius = material.BorderRadius.circular(radius); + final textStyle = material.DefaultTextStyle.of(context).style; + final iconTheme = material.IconTheme.of(context); + + final card = material.Material( color: theme.popover, elevation: 0, shape: material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular(radius), + borderRadius: borderRadius, side: material.BorderSide(color: borderColor ?? theme.border), ), clipBehavior: material.Clip.antiAlias, - child: constraints == null - ? child - : material.ConstrainedBox( - constraints: constraints!, - child: child, - ), + child: material.DefaultTextStyle( + style: textStyle, + child: material.IconTheme( + data: iconTheme, + child: child, + ), + ), + ); + + if (constraints == null) return card; + + return material.Container( + constraints: constraints, + child: card, ); } } From 662be2010a3f7b346f7123b1c5b4d5e9e5e3c7a0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 22:24:33 +0300 Subject: [PATCH 28/44] feat(connections): edit existing connection from sidebar context menu (#510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Edit connection… across driver tiles, reuse create forms with initial values, preserve blank passwords via secure-store merge, and reconnect active sessions after save. --- lib/core/storage/local_db.dart | 48 ++- .../connections/connection_creation_flow.dart | 130 +++++++- .../connections/connection_url_parser.dart | 15 +- .../connections/connections_panel.dart | 32 ++ .../connections_panel_extension.dart | 300 ++++++++++-------- .../connections/connections_panel_mongo.dart | 8 + .../connections/connections_panel_mysql.dart | 8 + ...connections_panel_postgres_connection.dart | 8 + .../connections/connections_panel_redis.dart | 8 + .../connections_panel_sidebar.dart | 9 + .../connections/connections_panel_sqlite.dart | 8 + .../extension_connection_form.dart | 88 ++++- .../connections/sqlite_connection_form.dart | 73 +++-- .../connections/ssl_certificate_support.dart | 3 +- .../mongodb/mongodb_connection_form.dart | 52 ++- lib/features/mysql/mysql_connection_form.dart | 48 ++- .../postgresql_connection_form.dart | 73 +++-- lib/features/redis/redis_connection_form.dart | 47 ++- test/core/storage/local_db_secrets_test.dart | 61 +++- .../connection_edit_helpers_test.dart | 50 +++ .../postgresql_connection_form_test.dart | 128 +++++++- 21 files changed, 957 insertions(+), 240 deletions(-) create mode 100644 test/features/connections/connection_edit_helpers_test.dart diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index bd7ce795..981538be 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -193,7 +193,8 @@ class LocalDb { } if (oldVersion < 7) { await db.execute('ALTER TABLE connections ADD COLUMN extension_id TEXT'); - await db.execute('ALTER TABLE connections ADD COLUMN driver_options TEXT'); + await db + .execute('ALTER TABLE connections ADD COLUMN driver_options TEXT'); } if (oldVersion < 8) { await db.execute('DROP INDEX IF EXISTS idx_sql_query_history_lookup'); @@ -441,7 +442,8 @@ class LocalDb { /// restored (best effort) and the error is rethrown. Future updateConnection(ConnectionRow row) async { if (row.id == null) { - throw ArgumentError('ConnectionRow.id cannot be null when calling updateConnection'); + throw ArgumentError( + 'ConnectionRow.id cannot be null when calling updateConnection'); } final db = await _open(); final previousMaps = await db.query( @@ -640,4 +642,46 @@ class ConnectionRow { sortOrder: _sqliteInt(m['sort_order']) ?? 0, createdAt: m['created_at'] as String, ); + + ConnectionRow copyWith({ + int? id, + String? type, + String? name, + String? host, + int? port, + String? username, + String? password, + String? databaseName, + String? authSource, + bool? useSSL, + String? connectionString, + String? extensionId, + String? driverOptions, + int? folderId, + int? sortOrder, + String? createdAt, + bool clearPassword = false, + bool clearConnectionString = false, + }) { + return ConnectionRow( + id: id ?? this.id, + type: type ?? this.type, + name: name ?? this.name, + host: host ?? this.host, + port: port ?? this.port, + username: username ?? this.username, + password: clearPassword ? null : (password ?? this.password), + databaseName: databaseName ?? this.databaseName, + authSource: authSource ?? this.authSource, + useSSL: useSSL ?? this.useSSL, + connectionString: clearConnectionString + ? null + : (connectionString ?? this.connectionString), + extensionId: extensionId ?? this.extensionId, + driverOptions: driverOptions ?? this.driverOptions, + folderId: folderId ?? this.folderId, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + ); + } } diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index 31e15cab..a2d74794 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -1,5 +1,8 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; - +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connection_type_choice.dart'; import 'package:querya_desktop/features/connections/extension_connection_form.dart'; @@ -61,3 +64,128 @@ Future promptCreateConnection( : null, }; } + +/// Opens the matching form prefilled for [existing] (type/driver fixed). +Future promptEditConnection( + material.BuildContext context, + ConnectionRow existing, +) async { + final dialogContext = _dialogAnchorContext(context); + if (!dialogContext.mounted) return null; + + if (ExtensionDriverCatalog.isExtensionDriverConnection(existing)) { + final manifest = ExtensionDriverCatalog.manifestForConnection(existing); + if (manifest == null) return null; + final driver = _driverForConnection(existing, manifest.contributedDrivers); + if (driver == null) return null; + return showExtensionConnectionForm( + dialogContext, + manifest: manifest, + driver: driver, + folderId: existing.folderId, + initial: existing, + ); + } + + return switch (existing.type) { + 'postgresql' => showPostgresConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'mysql' => showMysqlConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'mongodb' => showMongoConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'redis' => showRedisConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'sqlite' => showSqliteConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + _ => null, + }; +} + +DriverContribution? _driverForConnection( + ConnectionRow row, + Iterable drivers, +) { + final type = row.type.trim().toLowerCase(); + DriverContribution? first; + for (final driver in drivers) { + first ??= driver; + if (driver.driverId.trim().toLowerCase() == type) return driver; + } + return first; +} + +/// Keeps previous secure-store secrets when edit form fields are left blank. +/// +/// [ConnectionSecretsStore.writeForConnection] deletes empty values — callers +/// must merge before [LocalDb.updateConnection]. +Future mergeSecretsForConnectionUpdate( + ConnectionRow edited, +) async { + final id = edited.id; + if (id == null) { + throw ArgumentError('edited.id is required for secret merge'); + } + final prev = await ConnectionSecretsStore.readForConnection(id); + + final passwordEmpty = + edited.password == null || edited.password!.trim().isEmpty; + final password = passwordEmpty ? prev.password : edited.password; + + var connectionString = edited.connectionString; + if (connectionString == null || connectionString.trim().isEmpty) { + // Host-mode edit: do not resurrect a previous URI. + connectionString = null; + } else { + connectionString = injectUriPasswordIfMissing(connectionString, password); + } + + return edited.copyWith( + password: password, + connectionString: connectionString, + clearPassword: password == null, + clearConnectionString: connectionString == null, + ); +} + +/// Strips userinfo password so edit forms never show stored secrets. +String? redactUriPassword(String? uri) { + if (uri == null || uri.trim().isEmpty) return uri; + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null) return uri; + final info = parsed.userInfo; + if (info.isEmpty || !info.contains(':')) return uri; + final user = info.split(':').first; + return parsed.replace(userInfo: user).toString(); +} + +/// Puts [password] into URI userinfo when the URI has a user but no password. +@visibleForTesting +String injectUriPasswordIfMissing(String uri, String? password) { + if (password == null || password.isEmpty) return uri; + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null) return uri; + final info = parsed.userInfo; + if (info.isEmpty) return uri; + final parts = info.split(':'); + if (parts.length >= 2 && parts.sublist(1).join(':').isNotEmpty) { + return uri; + } + final user = parts.first; + return parsed.replace(userInfo: '$user:$password').toString(); +} diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart index 82ca3723..6ad7ed61 100644 --- a/lib/features/connections/connection_url_parser.dart +++ b/lib/features/connections/connection_url_parser.dart @@ -64,16 +64,15 @@ const _validPostgresSslModes = { if (!_validPostgresSslModes.contains(sslMode)) { return ( useSSL: null, - error: - 'Unsupported sslmode "$sslMode" for PostgreSQL. ' + error: 'Unsupported sslmode "$sslMode" for PostgreSQL. ' 'Supported: disable, require, verify-ca, verify-full.', ); } useSSL = sslMode != 'disable'; } } else if (type != 'sqlite') { - final sslQuery = uri.queryParameters['sslmode'] ?? - uri.queryParameters['ssl']; + final sslQuery = + uri.queryParameters['sslmode'] ?? uri.queryParameters['ssl']; if (sslQuery != null) { final lowerSsl = sslQuery.toLowerCase(); if (lowerSsl == 'true' || lowerSsl == 'require') { @@ -163,8 +162,8 @@ ConnectionRow? _buildConnectionRow( databaseName = null; } - authSource = uri.queryParameters['authSource'] ?? - uri.queryParameters['authsource']; + authSource = + uri.queryParameters['authSource'] ?? uri.queryParameters['authsource']; if (type == 'postgresql' || type == 'mysql' || type == 'mongodb') { connectionString = url; @@ -196,7 +195,9 @@ String _connectionName( int? defaultPort, ) { if (type == 'sqlite') { - return host == ':memory:' ? 'SQLite (Memory)' : 'SQLite (${host!.split('/').last})'; + return host == ':memory:' + ? 'SQLite (Memory)' + : 'SQLite (${host!.split('/').last})'; } final cleanHost = host ?? 'localhost'; diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 1730bc14..e801fefb 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -320,6 +320,31 @@ class ConnectionsPanelState extends State { } } + Future _editConnection(ConnectionRow conn) async { + final edited = await promptEditConnection(context, conn); + if (edited == null || !mounted) return; + final toSave = await mergeSecretsForConnectionUpdate(edited); + await LocalDb.instance.updateConnection(toSave); + await _loadData(); + if (!mounted) return; + ConnectionRow? updated; + for (final c in _connections) { + if (c.id == conn.id) { + updated = c; + break; + } + } + if (updated == null) return; + final shouldReconnect = _expandedConnections.contains(conn.id) || + widget.selectedConnectionId == conn.id; + if (shouldReconnect) { + await reconnect(updated); + } + if (widget.selectedConnectionId == conn.id) { + widget.onConnectionSelected?.call(updated); + } + } + Future _removeConnection(int id) async { await MongoService.instance.disconnectByConnectionId(id); await ExtensionDriverSession.instance.disconnect(id); @@ -426,6 +451,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, @@ -439,6 +465,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onMysqlObjectSelected: widget.onMysqlObjectSelected, onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, @@ -452,6 +479,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), isExpanded: isExpanded, @@ -464,6 +492,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), isExpanded: isExpanded, @@ -476,6 +505,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onSqliteObjectSelected: widget.onSqliteObjectSelected, onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, @@ -489,6 +519,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onObjectSelected: widget.onExtensionObjectSelected, isExpanded: isExpanded, @@ -501,6 +532,7 @@ class ConnectionsPanelState extends State { icon: QueryaIcons.connectionIcon(conn.type), iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), ); } diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 036a9dc0..e93bfa3f 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -8,6 +8,7 @@ class _ExtensionConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onObjectSelected, this.isExpanded = false, @@ -19,6 +20,7 @@ class _ExtensionConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; /// Fires when a table/view node is clicked in the schema tree. @@ -164,168 +166,184 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { ); } - return material.Padding( - padding: const material.EdgeInsets.only(bottom: 2), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Row( - children: [ - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.Semantics( - button: true, - expanded: widget.isExpanded, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: widget.isExpanded ? 0.25 : 0, - duration: - context.motionDuration(QueryaMotion.treeExpand), - curve: - context.motionCurve(QueryaMotion.treeExpandCurve), - child: material.Icon( - QueryaIcons.expandClosed, - size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.Semantics( + button: true, + expanded: widget.isExpanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: widget.isExpanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), ), ), - ), - material.Expanded( - child: _sidebarConnectionShell( - context: context, - isSelected: widget.isSelected, - onTap: widget.onTap, - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 4, - vertical: 6, - ), - child: material.Row( - children: [ - iconWidget, - const Gap(8), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Text( - widget.connection.name, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - fontWeight: widget.isSelected - ? material.FontWeight.w600 - : material.FontWeight.w500, - color: theme.colorScheme.foreground, - ), - ), - if (widget.connection.host != null) + material.Expanded( + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ material.Text( - '${widget.connection.host}:${widget.connection.port ?? ''}', + widget.connection.name, overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, + fontSize: 13, + fontWeight: widget.isSelected + ? material.FontWeight.w600 + : material.FontWeight.w500, + color: theme.colorScheme.foreground, ), ), - ], + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), ), - ), - material.Tooltip( - message: 'Remove', - child: material.InkWell( - onTap: widget.onRemove, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon( - material.Icons.close_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, + material.Tooltip( + message: 'Remove', + child: material.InkWell( + onTap: widget.onRemove, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon( + material.Icons.close_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), ), ), ), - ), - ], + ], + ), ), ), ), - ), - ], - ), - QueryaAnimatedExpand( - expanded: widget.isExpanded, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - if (_loading) - material.Padding( - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 4, - ), - child: material.Row( - children: [ - const material.SizedBox( - width: 12, - height: 12, - child: material.CircularProgressIndicator( - strokeWidth: 1.5, + ], + ), + QueryaAnimatedExpand( + expanded: widget.isExpanded, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + if (_loading) + material.Padding( + padding: const material.EdgeInsets.only( + left: 28, + top: 4, + bottom: 4, + ), + child: material.Row( + children: [ + const material.SizedBox( + width: 12, + height: 12, + child: material.CircularProgressIndicator( + strokeWidth: 1.5, + ), ), - ), - const Gap(8), - const Text('Loading...').muted().xSmall(), - ], - ), - ) - else if (_error != null) - TreeLoadError( - title: 'Could not load extension tree', - message: _error!, - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 8, + const Gap(8), + const Text('Loading...').muted().xSmall(), + ], + ), + ) + else if (_error != null) + TreeLoadError( + title: 'Could not load extension tree', + message: _error!, + padding: const material.EdgeInsets.only( + left: 28, + top: 4, + bottom: 8, + ), + onRetry: _loadTree, + ) + else if (_schema != null) + material.Padding( + padding: const material.EdgeInsets.only(left: 20), + child: _schema!.roots.isEmpty + ? material.Padding( + padding: const material.EdgeInsets.fromLTRB( + 0, 8, 8, 8), + child: const Text( + 'No databases found on this server.', + ).muted().small(), + ) + : SduiTreeBuilder( + schema: _schema!, + fetchChildren: _fetchChildren, + onNodeSelected: _onNodeSelected, + maxHeight: kConnectionTreeMaxVisibleRows * + kConnectionTreeRowExtent, + ), ), - onRetry: _loadTree, - ) - else if (_schema != null) - material.Padding( - padding: const material.EdgeInsets.only(left: 20), - child: _schema!.roots.isEmpty - ? material.Padding( - padding: - const material.EdgeInsets.fromLTRB(0, 8, 8, 8), - child: const Text( - 'No databases found on this server.', - ).muted().small(), - ) - : SduiTreeBuilder( - schema: _schema!, - fetchChildren: _fetchChildren, - onNodeSelected: _onNodeSelected, - maxHeight: kConnectionTreeMaxVisibleRows * - kConnectionTreeRowExtent, - ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index d745ccf8..2ee9b6a0 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -9,6 +9,7 @@ class _MongoConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onDatabaseTap, this.isExpanded = false, @@ -20,6 +21,7 @@ class _MongoConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function(String database)? onDatabaseTap; final bool isExpanded; @@ -172,6 +174,12 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { }, child: const Text('Refresh databases'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index a6f08dc1..abe2b92d 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -9,6 +9,7 @@ class _MysqlConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onMysqlObjectSelected, this.onMysqlOpenSqlWorkspace, @@ -21,6 +22,7 @@ class _MysqlConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -139,6 +141,12 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { widget.onMysqlOpenSqlWorkspace!(widget.connection), child: const Text('Open in SQL'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index c6ecc336..5a50066f 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -9,6 +9,7 @@ class _PostgresConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onPostgresObjectSelected, this.onPostgresOpenSqlWorkspace, @@ -21,6 +22,7 @@ class _PostgresConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -134,6 +136,12 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { }, child: const Text('Refresh databases'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index fb0f650b..6e6f509f 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -9,6 +9,7 @@ class _RedisConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onDatabaseTap, this.isExpanded = false, @@ -20,6 +21,7 @@ class _RedisConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function(int database)? onDatabaseTap; final bool isExpanded; @@ -149,6 +151,12 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { }, child: const Text('Refresh databases'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 8d981b0b..4540fe66 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -75,6 +75,7 @@ class _ConnectionTile extends StatelessWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, }); @@ -83,6 +84,7 @@ class _ConnectionTile extends StatelessWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; @override @@ -107,6 +109,12 @@ class _ConnectionTile extends StatelessWidget { ); return ContextMenu( items: [ + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), @@ -311,6 +319,7 @@ class _FolderTileState extends State<_FolderTile> { conn.type, ), onRemove: () => widget.onRemoveConnection(conn.id!), + onEdit: () {}, onTap: () => widget.onConnectionTap?.call(conn), ); }, diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 1b5dcb01..6be778e4 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -12,6 +12,7 @@ class _SqliteConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onSqliteObjectSelected, this.onSqliteOpenSqlWorkspace, @@ -24,6 +25,7 @@ class _SqliteConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -146,6 +148,12 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { widget.onSqliteOpenSqlWorkspace!(widget.connection), child: const Text('Open in SQL'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index 3ed40d4a..08ee32c1 100644 --- a/lib/features/connections/extension_connection_form.dart +++ b/lib/features/connections/extension_connection_form.dart @@ -18,6 +18,7 @@ Future showExtensionConnectionForm( required ExtensionManifest manifest, required DriverContribution driver, int? folderId, + ConnectionRow? initial, }) { return showAppDialog( context: context, @@ -28,6 +29,7 @@ Future showExtensionConnectionForm( manifest: manifest, driver: driver, folderId: folderId, + initial: initial, ), ), ); @@ -38,11 +40,13 @@ class _ExtensionConnectionFormContent extends material.StatefulWidget { required this.manifest, required this.driver, this.folderId, + this.initial, }); final ExtensionManifest manifest; final DriverContribution driver; final int? folderId; + final ConnectionRow? initial; @override material.State<_ExtensionConnectionFormContent> createState() => @@ -59,11 +63,21 @@ class _ExtensionConnectionFormContentState var _testing = false; String? _testMessage; bool _testSucceeded = false; + late final Map _initialValues; + + bool get _isEditing => widget.initial != null; @override void initState() { super.initState(); - _nameController.text = widget.driver.displayName; + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _initialValues = _sduiInitialValuesFromConnection(initial); + } else { + _nameController.text = widget.driver.displayName; + _initialValues = const {}; + } _loadSchema(); } @@ -111,6 +125,7 @@ class _ExtensionConnectionFormContentState name: name, values: values, folderId: widget.folderId, + initial: widget.initial, ); material.Navigator.of(context).pop(row); } @@ -144,6 +159,7 @@ class _ExtensionConnectionFormContentState driver: widget.driver, name: 'connection-test', values: values, + initial: widget.initial, ); final version = await ExtensionDriverSession.instance.testConnection( manifest: widget.manifest, @@ -171,6 +187,9 @@ class _ExtensionConnectionFormContentState material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; + final title = _isEditing + ? 'Edit ${widget.driver.displayName}' + : widget.driver.displayName; return material.Container( constraints: WindowLayout.dialogConstraints( context, @@ -193,7 +212,7 @@ class _ExtensionConnectionFormContentState child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - Text(widget.driver.displayName).large().semiBold(), + Text(title).large().semiBold(), const material.SizedBox(height: 6), Text( 'Extension driver · ${widget.manifest.id}', @@ -224,7 +243,11 @@ class _ExtensionConnectionFormContentState else if (_loadError != null) Text(_loadError!).muted().small() else if (_schema != null) - SduiFormBuilder(key: _formKey, schema: _schema!), + SduiFormBuilder( + key: _formKey, + schema: _schema!, + initialValues: _initialValues, + ), if (_testMessage != null) ...[ const material.SizedBox(height: 12), material.SelectableText( @@ -292,6 +315,54 @@ class _ExtensionConnectionFormContentState } } +/// Non-secret SDUI seed values from an existing [ConnectionRow] (no passwords). +Map _sduiInitialValuesFromConnection(ConnectionRow row) { + final values = {}; + + final optionsRaw = row.driverOptions; + if (optionsRaw != null && optionsRaw.trim().isNotEmpty) { + try { + final decoded = jsonDecode(optionsRaw); + if (decoded is Map) { + for (final entry in decoded.entries) { + final key = entry.key.toString(); + if (_isPasswordKey(key)) continue; + values[key] = entry.value; + } + } + } catch (_) { + // Ignore malformed driverOptions; host fields still apply. + } + } + + final host = row.host; + if (host != null && host.isNotEmpty) values['host'] = host; + if (row.port != null) values['port'] = row.port; + final username = row.username; + if (username != null && username.isNotEmpty) values['username'] = username; + final database = row.databaseName; + if (database != null && database.isNotEmpty) { + values['database'] = database; + values['databaseName'] = database; + } + values['useSSL'] = row.useSSL; + values['ssl'] = row.useSSL; + if (row.useSSL) { + values.putIfAbsent('sslMode', () => 'require'); + } + + values.removeWhere((key, _) => _isPasswordKey(key)); + return values; +} + +bool _isPasswordKey(String key) { + final lower = key.toLowerCase(); + return lower == 'password' || + lower.endsWith('password') || + lower.contains('secret') || + lower.contains('passwd'); +} + /// Loads SDUI form schema from the extension package (file path preferred). Future loadDriverConnectionFormSchema({ required ExtensionManifest manifest, @@ -321,6 +392,7 @@ ConnectionRow connectionRowFromExtensionForm({ required String name, required Map values, int? folderId, + ConnectionRow? initial, }) { final known = { 'host', @@ -358,7 +430,8 @@ ConnectionRow connectionRowFromExtensionForm({ } return ConnectionRow( - type: driver.driverId, + id: initial?.id, + type: initial?.type ?? driver.driverId, name: name, host: (host == null || host.isEmpty) ? null : host, port: port, @@ -366,9 +439,10 @@ ConnectionRow connectionRowFromExtensionForm({ password: (password == null || password.isEmpty) ? null : password, databaseName: database, useSSL: useSsl, - extensionId: manifest.id, + extensionId: initial?.extensionId ?? manifest.id, driverOptions: options.isEmpty ? null : jsonEncode(options), - folderId: folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + folderId: initial?.folderId ?? folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); } diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index 496dbc01..1ca2f717 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -13,21 +13,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showSqliteConnectionForm( material.BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _SqliteConnectionFormContent(folderId: folderId), + child: _SqliteConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _SqliteConnectionFormContent extends material.StatefulWidget { - const _SqliteConnectionFormContent({this.folderId}); + const _SqliteConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_SqliteConnectionFormContent> createState() => @@ -45,12 +50,22 @@ class _SqliteConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); _formValidNotifier = FormValidityNotifier(_computeFormValid); _formValidNotifier.listenTo(_nameController); _formValidNotifier.listenTo(_pathController); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _pathController.text = initial.host ?? ''; + _readOnly = initial.useSSL; + } + _formValidNotifier.seed(); } @@ -136,14 +151,18 @@ class _SqliteConnectionFormContentState void _save() { if (!_formValidNotifier.value) return; + final initial = widget.initial; final row = ConnectionRow( - id: null, - type: 'sqlite', + id: initial?.id, + type: initial?.type ?? 'sqlite', name: _nameController.text.trim(), host: _pathController.text.trim(), useSSL: _readOnly, // Store read-only toggle in useSSL field - createdAt: DateTime.now().toIso8601String(), - folderId: widget.folderId, + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, ); material.Navigator.of(context).pop(row); } @@ -163,11 +182,13 @@ class _SqliteConnectionFormContentState ), decoration: material.BoxDecoration( color: theme.popover, - borderRadius: material.BorderRadius.circular(Theme.of(context).radiusXxl), + borderRadius: + material.BorderRadius.circular(Theme.of(context).radiusXxl), border: material.Border.all(color: theme.muted), ), child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(Theme.of(context).radiusXxl), + borderRadius: + material.BorderRadius.circular(Theme.of(context).radiusXxl), child: material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, @@ -178,7 +199,11 @@ class _SqliteConnectionFormContentState child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - const Text('New SQLite Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit SQLite Connection' + : 'New SQLite Connection', + ).large().semiBold(), const Gap(6), const Text('Connect to a local SQLite database file.') .muted() @@ -193,7 +218,8 @@ class _SqliteConnectionFormContentState ), child: material.SingleChildScrollView( physics: const material.ClampingScrollPhysics(), - padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 12), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ @@ -229,7 +255,8 @@ class _SqliteConnectionFormContentState children: [ material.Checkbox( value: _readOnly, - onChanged: (v) => setState(() => _readOnly = v ?? false), + onChanged: (v) => + setState(() => _readOnly = v ?? false), ), const Gap(8), const Text('Read-only mode').small(), @@ -250,7 +277,8 @@ class _SqliteConnectionFormContentState onTap: _dismissResult, borderRadius: material.BorderRadius.circular(8), child: material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 10), decoration: material.BoxDecoration( color: _testResult == 'success' ? theme.primary.withValues(alpha: 0.12) @@ -270,7 +298,9 @@ class _SqliteConnectionFormContentState ? material.Icons.check_circle_outline : material.Icons.info_outline_rounded, size: 18, - color: _testResult == 'success' ? theme.primary : theme.destructive, + color: _testResult == 'success' + ? theme.primary + : theme.destructive, ), const Gap(10), material.Expanded( @@ -306,7 +336,8 @@ class _SqliteConnectionFormContentState ), // Footer material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), child: ValueListenableBuilder( valueListenable: _formValidNotifier.listenable, builder: (context, formValid, _) { @@ -316,7 +347,8 @@ class _SqliteConnectionFormContentState alignment: material.WrapAlignment.spaceBetween, children: [ OutlineButton( - onPressed: formValid && !_isTesting ? _testConnection : null, + onPressed: + formValid && !_isTesting ? _testConnection : null, leading: _isTesting ? material.SizedBox( width: 18, @@ -329,13 +361,17 @@ class _SqliteConnectionFormContentState : material.Icon( material.Icons.link_rounded, size: 18, - color: formValid ? theme.primary : theme.mutedForeground, + color: formValid + ? theme.primary + : theme.mutedForeground, ), child: Text( 'Test Connection', style: material.TextStyle( fontWeight: material.FontWeight.w500, - color: formValid ? theme.primary : theme.mutedForeground, + color: formValid + ? theme.primary + : theme.mutedForeground, ), ), ), @@ -343,7 +379,8 @@ class _SqliteConnectionFormContentState mainAxisSize: material.MainAxisSize.min, children: [ GhostButton( - onPressed: () => material.Navigator.of(context).pop(), + onPressed: () => + material.Navigator.of(context).pop(), child: const Text('Cancel'), ), const Gap(12), diff --git a/lib/features/connections/ssl_certificate_support.dart b/lib/features/connections/ssl_certificate_support.dart index 3d304331..bcb98239 100644 --- a/lib/features/connections/ssl_certificate_support.dart +++ b/lib/features/connections/ssl_certificate_support.dart @@ -25,7 +25,8 @@ class SslCertificatePaths { bool get hasAny => _nonEmpty(rootCert) || _nonEmpty(clientCert) || _nonEmpty(clientKey); - static bool _nonEmpty(String? value) => value != null && value.trim().isNotEmpty; + static bool _nonEmpty(String? value) => + value != null && value.trim().isNotEmpty; } SslCertificatePaths extractSslCertificatePaths(Uri uri) { diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index b1586c18..8ca301f4 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -46,21 +47,26 @@ class MongoConnectionData { Future showMongoConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _MongoConnectionFormContent(folderId: folderId), + child: _MongoConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _MongoConnectionFormContent extends material.StatefulWidget { - const _MongoConnectionFormContent({this.folderId}); + const _MongoConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_MongoConnectionFormContent> createState() => @@ -89,6 +95,8 @@ class _MongoConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -105,6 +113,23 @@ class _MongoConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? 'localhost'; + _portController.text = (initial.port ?? 27017).toString(); + _usernameController.text = initial.username ?? ''; + _databaseController.text = initial.databaseName ?? ''; + _authSourceController.text = initial.authSource ?? ''; + _useSSL = initial.useSSL; + final redacted = redactUriPassword(initial.connectionString) ?? ''; + _connectionStringController.text = redacted; + if (redacted.isNotEmpty) { + _useConnectionString = true; + } + } + _formValidNotifier.seed(); } @@ -279,8 +304,10 @@ class _MongoConnectionFormContentState final displayName = data.name.isNotEmpty ? data.name : 'MongoDB ${data.host}:${data.port}'; + final initial = widget.initial; final row = ConnectionRow( - type: 'mongodb', + id: initial?.id, + type: initial?.type ?? 'mongodb', name: displayName, host: data.host, port: data.port, @@ -290,8 +317,11 @@ class _MongoConnectionFormContentState authSource: data.authSource, useSSL: data.useSSL, connectionString: data.connectionString, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); @@ -331,7 +361,11 @@ class _MongoConnectionFormContentState color: theme.primary, ), const Gap(12), - const Text('MongoDB Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit MongoDB Connection' + : 'MongoDB Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -455,7 +489,11 @@ class _MongoConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 2bd146c1..7c1d6af1 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -14,21 +15,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showMysqlConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _MysqlConnectionFormContent(folderId: folderId), + child: _MysqlConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _MysqlConnectionFormContent extends material.StatefulWidget { - const _MysqlConnectionFormContent({this.folderId}); + const _MysqlConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_MysqlConnectionFormContent> createState() => @@ -55,6 +61,8 @@ class _MysqlConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -73,6 +81,19 @@ class _MysqlConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? ''; + _portController.text = (initial.port ?? 3306).toString(); + _usernameController.text = initial.username ?? ''; + _databaseController.text = initial.databaseName ?? ''; + _useSSL = initial.useSSL; + _connectionStringController.text = + redactUriPassword(initial.connectionString) ?? ''; + } + _formValidNotifier.seed(); } @@ -215,8 +236,10 @@ class _MysqlConnectionFormContentState : (uri.isNotEmpty ? 'MySQL (URI)' : 'MySQL $host:$port${database.isNotEmpty ? '/$database' : ''}'); + final initial = widget.initial; final row = ConnectionRow( - type: 'mysql', + id: initial?.id, + type: initial?.type ?? 'mysql', name: displayName, host: uri.isNotEmpty ? null : host, port: uri.isNotEmpty ? null : port, @@ -229,8 +252,11 @@ class _MysqlConnectionFormContentState uri.isNotEmpty ? null : (database.isEmpty ? null : database), useSSL: _useSSL || _hasSslCertificateFields(), connectionString: uri.isEmpty ? null : uri, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); } @@ -308,7 +334,11 @@ class _MysqlConnectionFormContentState ), ), const Gap(12), - const Text('MySQL Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit MySQL Connection' + : 'MySQL Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -408,7 +438,11 @@ class _MysqlConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index a373fb1a..98bec1cf 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -13,21 +14,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showPostgresConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _PostgresConnectionFormContent(folderId: folderId), + child: _PostgresConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _PostgresConnectionFormContent extends material.StatefulWidget { - const _PostgresConnectionFormContent({this.folderId}); + const _PostgresConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_PostgresConnectionFormContent> createState() => @@ -54,6 +60,8 @@ class _PostgresConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -72,6 +80,20 @@ class _PostgresConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? ''; + _portController.text = (initial.port ?? 5432).toString(); + _usernameController.text = initial.username ?? ''; + _databaseController.text = initial.databaseName ?? ''; + _useSSL = initial.useSSL; + _connectionStringController.text = + redactUriPassword(initial.connectionString) ?? ''; + // Password left empty — mergeSecretsForConnectionUpdate keeps existing. + } + _formValidNotifier.seed(); } @@ -159,8 +181,10 @@ class _PostgresConnectionFormContentState String? sslKey, }) { final userInfoParts = [ - if (username != null && username.isNotEmpty) Uri.encodeComponent(username), - if (password != null && password.isNotEmpty) Uri.encodeComponent(password), + if (username != null && username.isNotEmpty) + Uri.encodeComponent(username), + if (password != null && password.isNotEmpty) + Uri.encodeComponent(password), ]; final queryParams = { if (sslRootCert != null && sslRootCert.isNotEmpty) @@ -298,8 +322,10 @@ class _PostgresConnectionFormContentState : (effectiveUri.isNotEmpty ? 'PostgreSQL: $effectiveHost:$effectivePort' : 'PostgreSQL $host:$port/$database'); + final initial = widget.initial; final row = ConnectionRow( - type: 'postgresql', + id: initial?.id, + type: initial?.type ?? 'postgresql', name: displayName, host: uriHost ?? (effectiveUri.isEmpty ? host : null), port: uriPort ?? (effectiveUri.isEmpty ? port : null), @@ -312,8 +338,11 @@ class _PostgresConnectionFormContentState effectiveUri.isNotEmpty ? null : (database.isEmpty ? null : database), useSSL: effectiveUseSSL, connectionString: effectiveUri.isEmpty ? null : effectiveUri, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); } @@ -329,15 +358,15 @@ class _PostgresConnectionFormContentState Text(label).xSmall().muted(), const Gap(4), material.Row( - children: [ - material.Expanded( - child: TextField( - key: Key(label), - controller: controller, - placeholder: const Text('/path/to/file.pem'), - onChanged: (_) => _syncUriSslParams(), - ), - ), + children: [ + material.Expanded( + child: TextField( + key: Key(label), + controller: controller, + placeholder: const Text('/path/to/file.pem'), + onChanged: (_) => _syncUriSslParams(), + ), + ), const Gap(8), GhostButton( onPressed: () => _pickCertificateFile(controller), @@ -423,7 +452,11 @@ class _PostgresConnectionFormContentState ), ), const Gap(12), - const Text('PostgreSQL Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit PostgreSQL Connection' + : 'PostgreSQL Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -530,7 +563,11 @@ class _PostgresConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index d864cd8a..8c2fbe1a 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -13,21 +14,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showRedisConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _RedisConnectionFormContent(folderId: folderId), + child: _RedisConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _RedisConnectionFormContent extends material.StatefulWidget { - const _RedisConnectionFormContent({this.folderId}); + const _RedisConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_RedisConnectionFormContent> createState() => @@ -53,6 +59,8 @@ class _RedisConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -69,6 +77,18 @@ class _RedisConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? ''; + _portController.text = (initial.port ?? 6379).toString(); + _usernameController.text = initial.username ?? ''; + _useSSL = initial.useSSL; + _connectionStringController.text = + redactUriPassword(initial.connectionString) ?? ''; + } + _formValidNotifier.seed(); } @@ -195,8 +215,10 @@ class _RedisConnectionFormContentState final port = int.tryParse(_portController.text.trim()) ?? 6379; final uri = _effectiveConnectionUri(); final displayName = name.isNotEmpty ? name : 'Redis $host:$port'; + final initial = widget.initial; final row = ConnectionRow( - type: 'redis', + id: initial?.id, + type: initial?.type ?? 'redis', name: displayName, host: uri.isNotEmpty ? null : host, port: uri.isNotEmpty ? null : port, @@ -207,8 +229,11 @@ class _RedisConnectionFormContentState _passwordController.text.isEmpty ? null : _passwordController.text, useSSL: _useSSL || _hasSslCertificateFields(), connectionString: uri.isEmpty ? null : uri, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); } @@ -272,7 +297,11 @@ class _RedisConnectionFormContentState material.Icon(material.Icons.memory_rounded, size: 24, color: theme.primary), const Gap(12), - const Text('Redis Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit Redis Connection' + : 'Redis Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -360,7 +389,11 @@ class _RedisConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( diff --git a/test/core/storage/local_db_secrets_test.dart b/test/core/storage/local_db_secrets_test.dart index 51032fd4..dacae451 100644 --- a/test/core/storage/local_db_secrets_test.dart +++ b/test/core/storage/local_db_secrets_test.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import '../../memory_secrets_backend.dart'; @@ -117,7 +118,9 @@ void main() { expect(s.connectionString, isNull); }); - test('updateConnection atomically updates SQLite row and secure-store secrets', () async { + test( + 'updateConnection atomically updates SQLite row and secure-store secrets', + () async { const initialRow = ConnectionRow( type: 'postgres', name: 'PG_Init', @@ -137,7 +140,8 @@ void main() { port: 5433, username: 'root', password: 'new-secret-password', - connectionString: 'postgres://root:new-secret-password@db.example.com:5433/mydb', + connectionString: + 'postgres://root:new-secret-password@db.example.com:5433/mydb', createdAt: '2026-01-01T00:00:00Z', ); await LocalDb.instance.updateConnection(updatedRow); @@ -149,14 +153,18 @@ void main() { expect(loaded.port, 5433); expect(loaded.username, 'root'); expect(loaded.password, 'new-secret-password'); - expect(loaded.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + expect(loaded.connectionString, + 'postgres://root:new-secret-password@db.example.com:5433/mydb'); final secrets = await ConnectionSecretsStore.readForConnection(id); expect(secrets.password, 'new-secret-password'); - expect(secrets.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + expect(secrets.connectionString, + 'postgres://root:new-secret-password@db.example.com:5433/mydb'); }); - test('removeConnection still deletes SQLite row when secure-store delete fails', () async { + test( + 'removeConnection still deletes SQLite row when secure-store delete fails', + () async { const row = ConnectionRow( type: 'redis', name: 'R3', @@ -174,7 +182,8 @@ void main() { expect(list.where((c) => c.id == id), isEmpty); }); - test('addConnection rolls back SQLite row when secure-store write fails', () async { + test('addConnection rolls back SQLite row when secure-store write fails', + () async { testMemorySecrets.failNextWrite = StateError('keychain write failed'); const row = ConnectionRow( type: 'redis', @@ -194,7 +203,9 @@ void main() { expect(list.where((c) => c.name == 'R4'), isEmpty); }); - test('updateConnection rolls back SQLite and secrets when secure-store write fails', () async { + test( + 'updateConnection rolls back SQLite and secrets when secure-store write fails', + () async { const initialRow = ConnectionRow( type: 'postgres', name: 'PG_Before', @@ -231,5 +242,41 @@ void main() { expect(loaded.username, 'admin'); expect(loaded.password, 'old-password'); }); + + test( + 'mergeSecretsForConnectionUpdate keeps password when form leaves it blank', + () async { + const initialRow = ConnectionRow( + type: 'postgresql', + name: 'PG', + host: 'localhost', + port: 5432, + username: 'admin', + password: 'keep-me', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(initialRow); + + final edited = ConnectionRow( + id: id, + type: 'postgresql', + name: 'PG Renamed', + host: 'db.example.com', + port: 5432, + username: 'admin', + password: null, + createdAt: '2026-01-01T00:00:00Z', + ); + final merged = await mergeSecretsForConnectionUpdate(edited); + expect(merged.password, 'keep-me'); + expect(merged.name, 'PG Renamed'); + expect(merged.host, 'db.example.com'); + + await LocalDb.instance.updateConnection(merged); + final loaded = (await LocalDb.instance.getConnections()) + .singleWhere((c) => c.id == id); + expect(loaded.password, 'keep-me'); + expect(loaded.name, 'PG Renamed'); + }); }); } diff --git a/test/features/connections/connection_edit_helpers_test.dart b/test/features/connections/connection_edit_helpers_test.dart new file mode 100644 index 00000000..2560c2b6 --- /dev/null +++ b/test/features/connections/connection_edit_helpers_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; + +void main() { + group('redactUriPassword', () { + test('strips password from userinfo', () { + expect( + redactUriPassword('postgresql://alice:s3cret@db.example:5432/app'), + 'postgresql://alice@db.example:5432/app', + ); + }); + + test('leaves uri without password unchanged', () { + const uri = 'postgresql://alice@db.example:5432/app'; + expect(redactUriPassword(uri), uri); + }); + }); + + group('injectUriPasswordIfMissing', () { + test('injects password when user has no password', () { + expect( + injectUriPasswordIfMissing( + 'postgresql://alice@db.example:5432/app', + 's3cret', + ), + 'postgresql://alice:s3cret@db.example:5432/app', + ); + }); + + test('keeps existing password', () { + const uri = 'postgresql://alice:keep@db.example:5432/app'; + expect(injectUriPasswordIfMissing(uri, 'other'), uri); + }); + }); + + group('ConnectionRow.copyWith', () { + test('can clear password with flag', () { + const row = ConnectionRow( + id: 1, + type: 'postgresql', + name: 'n', + password: 'x', + createdAt: 't', + ); + expect(row.copyWith(clearPassword: true).password, isNull); + expect(row.copyWith(password: 'y').password, 'y'); + }); + }); +} diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index adda7db4..a3461d88 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -90,7 +90,8 @@ void main() { expect(result, isNull); }); - testWidgets('Save from URI extracts host and port for display', (tester) async { + testWidgets('Save from URI extracts host and port for display', + (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); ConnectionRow? result; await tester.pumpWidget( @@ -114,7 +115,11 @@ void main() { await tester.enterText( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == + 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), 'postgresql://u:p@remote.example.com:5433/db', ); @@ -125,7 +130,8 @@ void main() { expect(result!.host, 'remote.example.com'); expect(result!.port, 5433); expect(result!.name, 'PostgreSQL: remote.example.com:5433'); - expect(result!.connectionString, 'postgresql://u:p@remote.example.com:5433/db'); + expect(result!.connectionString, + 'postgresql://u:p@remote.example.com:5433/db'); }); testWidgets('SSL certificate path is appended to the URI', (tester) async { @@ -149,7 +155,11 @@ void main() { await tester.enterText( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == + 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), 'postgresql://u:p@remote.example.com:5433/db', ); @@ -176,14 +186,19 @@ void main() { final uriField = tester.widget( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgresql://user:pass@host:5432/dbname?sslmode=require', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == + 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), ); expect(uriField.controller?.text, contains('sslrootcert')); expect(uriField.controller?.text, contains('root.pem')); }); - testWidgets('Save with SSL certs and no URI builds a connection URI', (tester) async { + testWidgets('Save with SSL certs and no URI builds a connection URI', + (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); ConnectionRow? result; await tester.pumpWidget( @@ -207,31 +222,52 @@ void main() { await tester.enterText( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'My PostgreSQL Server', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'My PostgreSQL Server', ), 'Cert PG', ); await tester.enterText( - find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'localhost', - ).first, + find + .byWidgetPredicate( + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'localhost', + ) + .first, 'pg.example.com', ); await tester.enterText( - find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgres', - ).first, + find + .byWidgetPredicate( + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'postgres', + ) + .first, 'appdb', ); await tester.enterText( - find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgres', - ).last, + find + .byWidgetPredicate( + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'postgres', + ) + .last, 'admin', ); await tester.enterText( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'Password', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'Password', ), 'secret', ); @@ -268,5 +304,63 @@ void main() { expect(result!.connectionString, contains('secret')); expect(result!.useSSL, true); }); + + testWidgets('edit mode prefills fields and keeps password empty', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + const initial = ConnectionRow( + id: 42, + type: 'postgresql', + name: 'Prod', + host: 'db.example.com', + port: 5433, + username: 'app', + password: 'must-not-appear', + databaseName: 'appdb', + createdAt: '2026-01-01T00:00:00Z', + ); + ConnectionRow? result; + + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showPostgresConnectionForm( + context, + initial: initial, + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Edit PostgreSQL Connection'), findsOneWidget); + expect(find.text('Leave blank to keep existing'), findsOneWidget); + expect(find.text('must-not-appear'), findsNothing); + expect(find.text('Prod'), findsOneWidget); + expect(find.text('db.example.com'), findsOneWidget); + expect(find.text('5433'), findsOneWidget); + expect(find.text('appdb'), findsOneWidget); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.id, 42); + expect(result!.type, 'postgresql'); + expect(result!.name, 'Prod'); + expect(result!.host, 'db.example.com'); + expect(result!.password, isNull); + expect(result!.createdAt, '2026-01-01T00:00:00Z'); + }); }); } From 54895e990b32dfb14aa286f7668033ae2d3e3082 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 22:34:21 +0300 Subject: [PATCH 29/44] fix(extensions): edit-connection password keep and UI parity Allow blank required SDUI passwords when editing, merge secrets on Test, and drop the duplicate inline Remove control on extension tiles. --- lib/core/sdui/sdui_form_builder.dart | 19 ++++- .../connections/connection_creation_flow.dart | 64 +---------------- .../connections/connection_edit_secrets.dart | 63 +++++++++++++++++ .../connections_panel_extension.dart | 15 ---- .../extension_connection_form.dart | 7 +- test/core/sdui/sdui_builders_test.dart | 70 +++++++++++++++++++ 6 files changed, 158 insertions(+), 80 deletions(-) create mode 100644 lib/features/connections/connection_edit_secrets.dart diff --git a/lib/core/sdui/sdui_form_builder.dart b/lib/core/sdui/sdui_form_builder.dart index d218b95d..0eeb1d89 100644 --- a/lib/core/sdui/sdui_form_builder.dart +++ b/lib/core/sdui/sdui_form_builder.dart @@ -15,6 +15,7 @@ class SduiFormBuilder extends material.StatefulWidget { this.initialValues = const {}, this.onChanged, this.filePicker, + this.keepExistingSecrets = false, }); final SduiFormSchema schema; @@ -24,6 +25,10 @@ class SduiFormBuilder extends material.StatefulWidget { /// Injectable file picker for tests. Defaults to `openFile`. final Future Function(SduiFormField field)? filePicker; + /// When true (edit connection), blank password fields are valid and show + /// "Leave blank to keep existing" — host merges stored secrets on save. + final bool keepExistingSecrets; + @override material.State createState() => SduiFormBuilderState(); } @@ -290,7 +295,7 @@ class SduiFormBuilderState extends material.State { ? material.TextInputType.number : material.TextInputType.text, decoration: material.InputDecoration( - hintText: field.placeholder, + hintText: _hintFor(field), ), validator: _validatorFor(field), ), @@ -299,10 +304,20 @@ class SduiFormBuilderState extends material.State { } } + String? _hintFor(SduiFormField field) { + if (widget.keepExistingSecrets && + field.type == SduiFieldType.password) { + return 'Leave blank to keep existing'; + } + return field.placeholder; + } + material.FormFieldValidator? _validatorFor(SduiFormField field) { return (value) { final text = value?.trim() ?? ''; - if (field.required && text.isEmpty) { + final allowBlankSecret = widget.keepExistingSecrets && + field.type == SduiFieldType.password; + if (field.required && text.isEmpty && !allowBlankSecret) { return '${field.label} is required'; } if (field.type == SduiFieldType.number && text.isNotEmpty) { diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index a2d74794..6e2b6474 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -1,8 +1,6 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; -import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connection_type_choice.dart'; import 'package:querya_desktop/features/connections/extension_connection_form.dart'; @@ -13,6 +11,8 @@ import 'package:querya_desktop/features/mysql/mysql_connection_form.dart'; import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:querya_desktop/features/redis/redis_connection_form.dart'; +export 'package:querya_desktop/features/connections/connection_edit_secrets.dart'; + /// Context that stays mounted after menu overlays close (multi-step dialog flow). material.BuildContext _dialogAnchorContext(material.BuildContext context) { final navigator = material.Navigator.maybeOf(context, rootNavigator: true); @@ -129,63 +129,3 @@ DriverContribution? _driverForConnection( } return first; } - -/// Keeps previous secure-store secrets when edit form fields are left blank. -/// -/// [ConnectionSecretsStore.writeForConnection] deletes empty values — callers -/// must merge before [LocalDb.updateConnection]. -Future mergeSecretsForConnectionUpdate( - ConnectionRow edited, -) async { - final id = edited.id; - if (id == null) { - throw ArgumentError('edited.id is required for secret merge'); - } - final prev = await ConnectionSecretsStore.readForConnection(id); - - final passwordEmpty = - edited.password == null || edited.password!.trim().isEmpty; - final password = passwordEmpty ? prev.password : edited.password; - - var connectionString = edited.connectionString; - if (connectionString == null || connectionString.trim().isEmpty) { - // Host-mode edit: do not resurrect a previous URI. - connectionString = null; - } else { - connectionString = injectUriPasswordIfMissing(connectionString, password); - } - - return edited.copyWith( - password: password, - connectionString: connectionString, - clearPassword: password == null, - clearConnectionString: connectionString == null, - ); -} - -/// Strips userinfo password so edit forms never show stored secrets. -String? redactUriPassword(String? uri) { - if (uri == null || uri.trim().isEmpty) return uri; - final parsed = Uri.tryParse(uri.trim()); - if (parsed == null) return uri; - final info = parsed.userInfo; - if (info.isEmpty || !info.contains(':')) return uri; - final user = info.split(':').first; - return parsed.replace(userInfo: user).toString(); -} - -/// Puts [password] into URI userinfo when the URI has a user but no password. -@visibleForTesting -String injectUriPasswordIfMissing(String uri, String? password) { - if (password == null || password.isEmpty) return uri; - final parsed = Uri.tryParse(uri.trim()); - if (parsed == null) return uri; - final info = parsed.userInfo; - if (info.isEmpty) return uri; - final parts = info.split(':'); - if (parts.length >= 2 && parts.sublist(1).join(':').isNotEmpty) { - return uri; - } - final user = parts.first; - return parsed.replace(userInfo: '$user:$password').toString(); -} diff --git a/lib/features/connections/connection_edit_secrets.dart b/lib/features/connections/connection_edit_secrets.dart new file mode 100644 index 00000000..700f4732 --- /dev/null +++ b/lib/features/connections/connection_edit_secrets.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Keeps previous secure-store secrets when edit form fields are left blank. +/// +/// [ConnectionSecretsStore.writeForConnection] deletes empty values — callers +/// must merge before [LocalDb.updateConnection]. +Future mergeSecretsForConnectionUpdate( + ConnectionRow edited, +) async { + final id = edited.id; + if (id == null) { + throw ArgumentError('edited.id is required for secret merge'); + } + final prev = await ConnectionSecretsStore.readForConnection(id); + + final passwordEmpty = + edited.password == null || edited.password!.trim().isEmpty; + final password = passwordEmpty ? prev.password : edited.password; + + var connectionString = edited.connectionString; + if (connectionString == null || connectionString.trim().isEmpty) { + // Host-mode edit: do not resurrect a previous URI. + connectionString = null; + } else { + connectionString = injectUriPasswordIfMissing(connectionString, password); + } + + return edited.copyWith( + password: password, + connectionString: connectionString, + clearPassword: password == null, + clearConnectionString: connectionString == null, + ); +} + +/// Strips userinfo password so edit forms never show stored secrets. +String? redactUriPassword(String? uri) { + if (uri == null || uri.trim().isEmpty) return uri; + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null) return uri; + final info = parsed.userInfo; + if (info.isEmpty || !info.contains(':')) return uri; + final user = info.split(':').first; + return parsed.replace(userInfo: user).toString(); +} + +/// Puts [password] into URI userinfo when the URI has a user but no password. +@visibleForTesting +String injectUriPasswordIfMissing(String uri, String? password) { + if (password == null || password.isEmpty) return uri; + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null) return uri; + final info = parsed.userInfo; + if (info.isEmpty) return uri; + final parts = info.split(':'); + if (parts.length >= 2 && parts.sublist(1).join(':').isNotEmpty) { + return uri; + } + final user = parts.first; + return parsed.replace(userInfo: '$user:$password').toString(); +} diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index e93bfa3f..f70afaa4 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -260,21 +260,6 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { ], ), ), - material.Tooltip( - message: 'Remove', - child: material.InkWell( - onTap: widget.onRemove, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon( - material.Icons.close_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - ), - ), ], ), ), diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index 08ee32c1..587dfad4 100644 --- a/lib/features/connections/extension_connection_form.dart +++ b/lib/features/connections/extension_connection_form.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/sdui/sdui_form_builder.dart'; import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_edit_secrets.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows an SDUI connection form for an installed extension driver. @@ -154,13 +155,16 @@ class _ExtensionConnectionFormContentState }); try { - final row = connectionRowFromExtensionForm( + var row = connectionRowFromExtensionForm( manifest: widget.manifest, driver: widget.driver, name: 'connection-test', values: values, initial: widget.initial, ); + if (widget.initial?.id != null) { + row = await mergeSecretsForConnectionUpdate(row); + } final version = await ExtensionDriverSession.instance.testConnection( manifest: widget.manifest, row: row, @@ -247,6 +251,7 @@ class _ExtensionConnectionFormContentState key: _formKey, schema: _schema!, initialValues: _initialValues, + keepExistingSecrets: _isEditing, ), if (_testMessage != null) ...[ const material.SizedBox(height: 12), diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart index 9bf1f381..1b85f76a 100644 --- a/test/core/sdui/sdui_builders_test.dart +++ b/test/core/sdui/sdui_builders_test.dart @@ -151,6 +151,76 @@ void main() { expect(key.currentState!.snapshotValues()['db'], '/tmp/test.db'); }); + + testWidgets( + 'keepExistingSecrets allows blank required password with hint', + (tester) async { + final schema = SduiFormSchema.fromJson(const { + 'fields': [ + {'id': 'host', 'type': 'text', 'label': 'Host', 'required': true}, + { + 'id': 'password', + 'type': 'password', + 'label': 'Password', + 'required': true, + 'placeholder': 'Secret', + }, + ], + }); + final key = material.GlobalKey(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiFormBuilder( + key: key, + schema: schema, + initialValues: const {'host': 'db.local'}, + keepExistingSecrets: true, + ), + ), + ), + ); + + expect(find.text('Leave blank to keep existing'), findsOneWidget); + expect(find.text('Secret'), findsNothing); + + final values = key.currentState!.collectValues(); + expect(values, isNotNull); + expect(values!['host'], 'db.local'); + expect(values['password'], ''); + expect(find.text('Password is required'), findsNothing); + }, + ); + + testWidgets( + 'required password still blocks create when keepExistingSecrets is false', + (tester) async { + final schema = SduiFormSchema.fromJson(const { + 'fields': [ + { + 'id': 'password', + 'type': 'password', + 'label': 'Password', + 'required': true, + }, + ], + }); + final key = material.GlobalKey(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiFormBuilder(key: key, schema: schema), + ), + ), + ); + + expect(key.currentState!.collectValues(), isNull); + await tester.pump(); + expect(find.text('Password is required'), findsOneWidget); + }, + ); }); group('SduiTreeSchema', () { From 022c1f993e11115fd8c57988f092d8bb2389daa6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 22:43:25 +0300 Subject: [PATCH 30/44] ui(connections): migrate connection forms to QueryaDialogCard Align create/edit shells with Preferences and Extension Manager so dialog chrome uses the shared Material + ambient text/icon pattern. --- .../extension_connection_form.dart | 20 ++++++------------ .../connections/sqlite_connection_form.dart | 21 +++++++------------ .../mongodb/mongodb_connection_form.dart | 18 +++++----------- lib/features/mysql/mysql_connection_form.dart | 18 +++++----------- .../postgresql_connection_form.dart | 18 +++++----------- lib/features/redis/redis_connection_form.dart | 18 +++++----------- 6 files changed, 33 insertions(+), 80 deletions(-) diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index 587dfad4..13845d83 100644 --- a/lib/features/connections/extension_connection_form.dart +++ b/lib/features/connections/extension_connection_form.dart @@ -190,27 +190,20 @@ class _ExtensionConnectionFormContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final title = _isEditing ? 'Edit ${widget.driver.displayName}' : widget.driver.displayName; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.connectionFormMaxWidth, minWidth: 440, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), child: material.Column( @@ -315,7 +308,6 @@ class _ExtensionConnectionFormContentState ), ], ), - ), ); } } diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index 1ca2f717..5185f211 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -174,21 +174,14 @@ class _SqliteConnectionFormContentState final dialogH = WindowLayout.newConnectionDialogHeight(context); final scrollH = dialogH - 120.0; // Subtract header and footer heights - return material.Container( + return material.SizedBox( width: dialogMaxW, - constraints: material.BoxConstraints( - maxWidth: dialogMaxW, - maxHeight: dialogH, - ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: - material.BorderRadius.circular(Theme.of(context).radiusXxl), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: - material.BorderRadius.circular(Theme.of(context).radiusXxl), + child: QueryaDialogCard( + constraints: material.BoxConstraints( + maxWidth: dialogMaxW, + maxHeight: dialogH, + ), + borderColor: theme.muted, child: material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 8ca301f4..88bf852d 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -330,24 +330,17 @@ class _MongoConnectionFormContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.connectionFormMaxWidth, maxHeight: WindowLayout.connectionFormMongoMaxHeight, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + borderColor: theme.muted, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: Column( @@ -706,7 +699,6 @@ class _MongoConnectionFormContentState ), ], ), - ), ); } } diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 7c1d6af1..de323651 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -295,24 +295,17 @@ class _MysqlConnectionFormContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.connectionFormMaxWidth, maxHeight: WindowLayout.connectionFormMaxHeight, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + borderColor: theme.muted, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: material.Column( @@ -619,7 +612,6 @@ class _MysqlConnectionFormContentState ), ], ), - ), ); } } diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 98bec1cf..b66d9e28 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -412,24 +412,17 @@ class _PostgresConnectionFormContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.connectionFormMaxWidth, maxHeight: WindowLayout.connectionFormMaxHeight, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + borderColor: theme.muted, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ // Header material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), @@ -765,7 +758,6 @@ class _PostgresConnectionFormContentState ), ], ), - ), ); } } diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 8c2fbe1a..f006c45f 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -269,24 +269,17 @@ class _RedisConnectionFormContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.connectionFormMaxWidth, maxHeight: WindowLayout.connectionFormMaxHeight, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + borderColor: theme.muted, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: material.Column( @@ -563,7 +556,6 @@ class _RedisConnectionFormContentState ), ], ), - ), ); } } From bb1cc57cce4e434b87785a66dca9d93f76ab7ccc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 09:01:54 +0300 Subject: [PATCH 31/44] perf(database): adaptive compute offload for large SQL result sets (#522) --- .../database/result_row_string_convert.dart | 36 ++++++++--- .../postgresql/postgres_sql_workspace.dart | 4 +- .../postgresql/postgres_table_view.dart | 4 +- lib/features/sqlite/sqlite_sql_workspace.dart | 4 +- .../result_row_string_convert_test.dart | 60 +++++++++++++++---- 5 files changed, 82 insertions(+), 26 deletions(-) diff --git a/lib/core/database/result_row_string_convert.dart b/lib/core/database/result_row_string_convert.dart index 16f9f254..f77ef8b7 100644 --- a/lib/core/database/result_row_string_convert.dart +++ b/lib/core/database/result_row_string_convert.dart @@ -1,17 +1,25 @@ -/// Converts SQL result cells to display strings without a second isolate copy. -/// -/// Prefer this over [compute] for large matrices: shipping `List>` -/// across isolates often costs more than `toString()` itself and roughly -/// doubles peak memory. Yielding every [yieldEvery] rows keeps the UI isolate -/// responsive for 10k+ row caps. -library; +import 'package:flutter/foundation.dart'; const int kResultStringConvertYieldEvery = 250; +const int kResultStringConvertComputeThreshold = 1000; /// Maps null cells to `'NULL'` and others via [Object.toString]. String resultCellToDisplayString(Object? value) => value == null ? 'NULL' : value.toString(); +/// Converts [rowValues] to string rows synchronously. +List> convertResultRowsToStringsSync(List> rowValues) { + if (rowValues.isEmpty) return const []; + return [ + for (final row in rowValues) + [for (final value in row) resultCellToDisplayString(value)], + ]; +} + +/// Top-level function suitable for [compute] offloading. +List> convertResultRowsToStringsCompute(List> rowValues) => + convertResultRowsToStringsSync(rowValues); + /// Converts [rowValues] to string rows, yielding periodically. Future>> convertResultRowsToStringsYielding( List> rowValues, { @@ -31,3 +39,17 @@ Future>> convertResultRowsToStringsYielding( } return out; } + +/// Converts [rowValues] adaptively: offloads to a background isolate via [compute] +/// if row count >= [computeThreshold], otherwise yields on the main isolate. +Future>> convertResultRowsToStringsAdaptive( + List> rowValues, { + int computeThreshold = kResultStringConvertComputeThreshold, + int yieldEvery = kResultStringConvertYieldEvery, +}) async { + if (rowValues.isEmpty) return const []; + if (rowValues.length >= computeThreshold) { + return compute(convertResultRowsToStringsCompute, rowValues); + } + return convertResultRowsToStringsYielding(rowValues, yieldEvery: yieldEvery); +} diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 0cd85bed..f511f59c 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -358,8 +358,8 @@ class _PostgresSqlWorkspaceState extends material.State { n++; } - // Yielding convert avoids isolate double-copy of the matrix (#421). - final outRows = await convertResultRowsToStringsYielding(rawRows); + // Adaptive convert offloads to background compute for large row sets (#522). + final outRows = await convertResultRowsToStringsAdaptive(rawRows); setState(() { _columns = cols; diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 636aa0ce..5a85e20b 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -200,7 +200,7 @@ class _PostgresTableViewState extends material.State { List.generate(row.length, (i) => row[i]), ]; - final stringRows = await convertResultRowsToStringsYielding(rawRows); + final stringRows = await convertResultRowsToStringsAdaptive(rawRows); if (!mounted) return; setState(() { @@ -257,7 +257,7 @@ class _PostgresTableViewState extends material.State { List.generate(row.length, (i) => row[i]), ]; - final stringRows = await convertResultRowsToStringsYielding(rawRows); + final stringRows = await convertResultRowsToStringsAdaptive(rawRows); if (!mounted) return; setState(() { diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 5a478613..0ecab186 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -177,8 +177,8 @@ class _SqliteSqlWorkspaceState extends material.State { return cols.map((col) => row[col]).toList(); }).toList(); - // Yielding convert avoids isolate double-copy of the matrix (#421). - final outRows = await convertResultRowsToStringsYielding(rawRows); + // Adaptive convert offloads to background compute for large row sets (#522). + final outRows = await convertResultRowsToStringsAdaptive(rawRows); setState(() { _columns = cols; diff --git a/test/core/database/result_row_string_convert_test.dart b/test/core/database/result_row_string_convert_test.dart index de451b1b..44d42535 100644 --- a/test/core/database/result_row_string_convert_test.dart +++ b/test/core/database/result_row_string_convert_test.dart @@ -2,24 +2,58 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; void main() { - group('convertResultRowsToStringsYielding', () { - test('maps null to NULL and yields without isolate', () async { - final rows = >[ - [1, null, 'a'], - [2, 'x', null], - ]; + group('result_row_string_convert', () { + final sampleRows = >[ + [1, null, 'a'], + [2, 'x', null], + ]; + + final expectedOutput = [ + ['1', 'NULL', 'a'], + ['2', 'x', 'NULL'], + ]; + + test('convertResultRowsToStringsSync maps rows correctly', () { + expect(convertResultRowsToStringsSync(sampleRows), expectedOutput); + expect(convertResultRowsToStringsSync(const []), isEmpty); + }); + + test('convertResultRowsToStringsCompute maps rows correctly', () { + expect(convertResultRowsToStringsCompute(sampleRows), expectedOutput); + expect(convertResultRowsToStringsCompute(const []), isEmpty); + }); + + test('convertResultRowsToStringsYielding maps null to NULL and yields', () async { final out = await convertResultRowsToStringsYielding( - rows, + sampleRows, yieldEvery: 1, ); - expect(out, [ - ['1', 'NULL', 'a'], - ['2', 'x', 'NULL'], - ]); + expect(out, expectedOutput); + expect(await convertResultRowsToStringsYielding(const []), isEmpty); + }); + + test('convertResultRowsToStringsAdaptive handles small payload via yielding', () async { + final out = await convertResultRowsToStringsAdaptive( + sampleRows, + computeThreshold: 100, + ); + expect(out, expectedOutput); + expect(await convertResultRowsToStringsAdaptive(const []), isEmpty); }); - test('empty input returns empty', () async { - expect(await convertResultRowsToStringsYielding(const []), isEmpty); + test('convertResultRowsToStringsAdaptive handles large payload via compute', () async { + final largeRows = List>.generate( + 10, + (i) => [i, null, 'val_$i'], + ); + final out = await convertResultRowsToStringsAdaptive( + largeRows, + computeThreshold: 5, + ); + expect(out.length, 10); + expect(out[0], ['0', 'NULL', 'val_0']); + expect(out[9], ['9', 'NULL', 'val_9']); }); }); } + From 424df91f62c280c4e4d1d6b2a73450f134c1951e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 10:09:55 +0300 Subject: [PATCH 32/44] perf(ui): use binary search for visible column window in VirtualResultGrid --- .../main_screen/result_grid_view.dart | 30 +++++++++++++++---- .../main_screen/results_tab_test.dart | 18 +++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index 9fc2becb..f56c3779 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -125,16 +125,34 @@ ResultGridColumnWindow computeVisibleColumnWindow({ final start = scrollOffset.clamp(0.0, total); final end = (scrollOffset + viewportWidth).clamp(0.0, total); - // First column with any pixel past [start]. + // First column with any pixel past [start]: smallest index where columnOffsets[first + 1] > start var first = 0; - while (first < n && columnOffsets[first + 1] <= start) { - first++; + var low = 0; + var high = n - 1; + while (low <= high) { + final mid = (low + high) ~/ 2; + if (columnOffsets[mid + 1] > start) { + first = mid; + high = mid - 1; + } else { + low = mid + 1; + } } - // Last column with any pixel before [end]. + + // Last column with any pixel before [end]: largest index where columnOffsets[last] < end var last = n - 1; - while (last > 0 && columnOffsets[last] >= end) { - last--; + low = 0; + high = n - 1; + while (low <= high) { + final mid = (low + high) ~/ 2; + if (columnOffsets[mid] < end) { + last = mid; + low = mid + 1; + } else { + high = mid - 1; + } } + if (first > last) { first = last.clamp(0, n - 1); } diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index e3bf96c1..2ed4a56a 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -106,6 +106,24 @@ void main() { expect(window.last, lessThan(30)); expect(window.leadingWidth, greaterThan(0)); }); + + test('handles large scale column sets (10000 columns) efficiently', () { + final widths = List.filled(10000, 100); + final offsets = computeResultGridColumnOffsets(widths); + final window = computeVisibleColumnWindow( + columnWidths: widths, + columnOffsets: offsets, + scrollOffset: 500000, + viewportWidth: 1000, + overscanColumns: 2, + ); + // scrollOffset 500000 = index 5000 (since width is 100) + // viewport 1000 = 10 columns (indices 5000..5009) + // with overscan 2 -> first: 4998, last: 5011 + expect(window.first, 4998); + expect(window.last, 5011); + expect(window.leadingWidth, 4998 * 100.0); + }); }); group('ResultsTab', () { From 35a60348eef00acf689ac0cf8f1b123eef6f7d9b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 10:18:37 +0300 Subject: [PATCH 33/44] perf(db): optimize SQL history pruning to use batching instead of per-insert COUNT(*) --- lib/core/storage/local_db.dart | 24 +++++++++++---- test/core/storage/sql_query_history_test.dart | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 981538be..337a4129 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -231,11 +231,14 @@ class LocalDb { await db.delete('app_settings', where: 'key = ?', whereArgs: [key]); } + final Map _historyInsertCounts = {}; + Future recordSqlQueryHistory({ required int connectionId, String? databaseName, required String sqlText, int maxEntries = kDefaultSqlHistoryCap, + bool forcePrune = false, }) async { final sql = sqlText.trim(); if (sql.isEmpty) return; @@ -249,12 +252,21 @@ class LocalDb { 'sql_text': sql, 'recorded_at': now, }); - await _pruneSqlQueryHistoryBucket( - db, - connectionId: connectionId, - databaseName: dbKey, - maxEntries: maxEntries, - ); + + final bucketKey = '$connectionId::${dbKey ?? ''}'; + final insertCount = (_historyInsertCounts[bucketKey] ?? 0) + 1; + _historyInsertCounts[bucketKey] = insertCount; + + final batchThreshold = maxEntries <= 10 ? 1 : 10; + if (forcePrune || insertCount >= batchThreshold) { + _historyInsertCounts[bucketKey] = 0; + await _pruneSqlQueryHistoryBucket( + db, + connectionId: connectionId, + databaseName: dbKey, + maxEntries: maxEntries, + ); + } } /// Keeps the newest [maxEntries] rows in a (connection, database) bucket. diff --git a/test/core/storage/sql_query_history_test.dart b/test/core/storage/sql_query_history_test.dart index e0342270..f7a2d6f5 100644 --- a/test/core/storage/sql_query_history_test.dart +++ b/test/core/storage/sql_query_history_test.dart @@ -124,6 +124,36 @@ void main() { expect(list.map((e) => e.sqlText), ['q4', 'q3', 'q2']); }); + test('prunes in batches when maxEntries > 10', () async { + const row = ConnectionRow( + type: 'mysql', + name: 'M2', + host: '127.0.0.1', + port: 3306, + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + + // Insert 25 items with maxEntries = 15 (batch threshold = 10) + for (var i = 0; i < 25; i++) { + await LocalDb.instance.recordSqlQueryHistory( + connectionId: id, + databaseName: 'db_batch', + sqlText: 'query_$i', + maxEntries: 15, + ); + } + + final list = await LocalDb.instance.listSqlQueryHistory( + connectionId: id, + databaseName: 'db_batch', + limit: 100, + ); + // On 20th insert (batch threshold 10 hit twice), pruned to 15. Then 5 more inserted (21..24) -> total 20 items. + expect(list.length, lessThanOrEqualTo(20)); + expect(list.first.sqlText, 'query_24'); + }); + test('history lookup index includes database_name', () async { await LocalDb.instance.getAppSetting('__touch__'); // ensure DB open final dbFile = p.join(tempDir.path, 'querya_desktop', 'querya.db'); From c8e73e780987856fe9e61f6c1e6c9e7a5c56de0a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 10:35:36 +0300 Subject: [PATCH 34/44] fix(sandbox): prevent false-positive SIGKILL in SandboxWatchdog during heavy RPC --- .../extensions/rpc/json_rpc_stdio_client.dart | 6 ++++ .../extensions/rpc/plugin_rpc_bridge.dart | 1 + .../extensions/sandbox/sandbox_watchdog.dart | 8 ++++-- .../sandbox/sandbox_watchdog_test.dart | 28 +++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/core/extensions/rpc/json_rpc_stdio_client.dart b/lib/core/extensions/rpc/json_rpc_stdio_client.dart index 130f56f5..4e0e5445 100644 --- a/lib/core/extensions/rpc/json_rpc_stdio_client.dart +++ b/lib/core/extensions/rpc/json_rpc_stdio_client.dart @@ -41,6 +41,12 @@ class JsonRpcStdioClient { StreamSubscription? _subscription; Object? _fatalError; + /// Number of RPC requests currently in-flight waiting for a response. + int get pendingRequestCount => _pending.length; + + /// Returns true when there is at least one active RPC request in-flight. + bool get hasPendingRequests => _pending.isNotEmpty; + /// Serializes async line handling so large-line isolate decode stays ordered. Future _lineChain = Future.value(); diff --git a/lib/core/extensions/rpc/plugin_rpc_bridge.dart b/lib/core/extensions/rpc/plugin_rpc_bridge.dart index 9e528bb3..4b0a61b9 100644 --- a/lib/core/extensions/rpc/plugin_rpc_bridge.dart +++ b/lib/core/extensions/rpc/plugin_rpc_bridge.dart @@ -117,6 +117,7 @@ class PluginRpcBridge { if (enableWatchdog) { _watchdog = SandboxWatchdog( recovery: _recovery, + isBusy: () => client.hasPendingRequests, onStopped: (reason) { if (reason == SandboxWatchdogStopReason.deadlock) { unawaited(_audit?.record( diff --git a/lib/core/extensions/sandbox/sandbox_watchdog.dart b/lib/core/extensions/sandbox/sandbox_watchdog.dart index 4a8c096a..16a25e10 100644 --- a/lib/core/extensions/sandbox/sandbox_watchdog.dart +++ b/lib/core/extensions/sandbox/sandbox_watchdog.dart @@ -25,16 +25,19 @@ enum SandboxWatchdogStopReason { class SandboxWatchdog { SandboxWatchdog({ this.pingInterval = const Duration(seconds: 30), - this.pongTimeout = const Duration(seconds: 5), + this.pongTimeout = const Duration(seconds: 15), this.recovery, + bool Function()? isBusy, Future Function()? ping, void Function(SandboxWatchdogStopReason reason)? onStopped, - }) : _pingOverride = ping, + }) : _isBusy = isBusy, + _pingOverride = ping, _onStopped = onStopped; final Duration pingInterval; final Duration pongTimeout; final SandboxAutoRecovery? recovery; + final bool Function()? _isBusy; final Future Function()? _pingOverride; final void Function(SandboxWatchdogStopReason reason)? _onStopped; @@ -107,6 +110,7 @@ class SandboxWatchdog { Future _tick() async { if (!_running || _pingInFlight) return; + if (_isBusy?.call() ?? false) return; _pingInFlight = true; try { final result = await _sendPing().timeout(pongTimeout); diff --git a/test/core/extensions/sandbox/sandbox_watchdog_test.dart b/test/core/extensions/sandbox/sandbox_watchdog_test.dart index d6038503..cbf08f2c 100644 --- a/test/core/extensions/sandbox/sandbox_watchdog_test.dart +++ b/test/core/extensions/sandbox/sandbox_watchdog_test.dart @@ -254,6 +254,34 @@ void main() { await client.close(); await handle.dispose(); }); + + test('skips ping tick when isBusy returns true', () async { + final process = _FakeProcess(); + final handle = await _handle(process, tempBase); + var pings = 0; + var busy = true; + + final watchdog = SandboxWatchdog( + pingInterval: const Duration(milliseconds: 15), + pongTimeout: const Duration(seconds: 1), + isBusy: () => busy, + ping: () async { + pings++; + return 'pong'; + }, + ); + + watchdog.start(handle); + await Future.delayed(const Duration(milliseconds: 50)); + expect(pings, 0, reason: 'Pings should be skipped while busy'); + + busy = false; + await Future.delayed(const Duration(milliseconds: 50)); + expect(pings, greaterThan(0), reason: 'Pings should resume when idle'); + + watchdog.stop(); + await handle.dispose(); + }); }); group('SandboxWatchdog.isPong', () { From 2407c0e033460f2065de82e23362df4bd7e0bc16 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 10:51:07 +0300 Subject: [PATCH 35/44] refactor: replace .map().toList() with collection for loops in UI components Resolves #530 --- lib/features/extensions/extension_stats_view.dart | 4 +++- lib/features/mongodb/mongo_stats_view.dart | 2 +- lib/features/redis/redis_view.dart | 11 +++++------ lib/shared/widgets/querya_dropdown.dart | 5 +++-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/features/extensions/extension_stats_view.dart b/lib/features/extensions/extension_stats_view.dart index f7788a59..5b0cb1e4 100644 --- a/lib/features/extensions/extension_stats_view.dart +++ b/lib/features/extensions/extension_stats_view.dart @@ -316,7 +316,9 @@ class _ExtensionStatsViewState extends material.State { mainAxisSpacing: 16, mainAxisExtent: _summaryChipHeight, ), - children: chips.map((c) => _buildChip(cs, c)).toList(), + children: [ + for (final c in chips) _buildChip(cs, c), + ], ); }, ); diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index cce26a9c..a19f14d2 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -690,7 +690,7 @@ class _MongoStatsViewState extends material.State { title, _twoColumnMetrics( context, - data.entries.map((e) => MapEntry(e.key, e.value)).toList(), + [ for (final e in data.entries) MapEntry(e.key, e.value) ], ), ); } diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 4ffa5d0a..db9096d7 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -680,11 +680,10 @@ class _RedisViewState extends material.State { {List? keys}) { if (data == null || data.isEmpty) return const material.SizedBox.shrink(); final entries = keys != null - ? keys - .map((k) => MapEntry(k, data[k])) - .where((e) => e.value != null) - .map((e) => MapEntry(e.key, e.value as String)) - .toList() + ? >[ + for (final k in keys) + if (data[k] != null) MapEntry(k, data[k]!), + ] : data.entries.toList(); if (entries.isEmpty) return const material.SizedBox.shrink(); return _card( @@ -692,7 +691,7 @@ class _RedisViewState extends material.State { title, _twoColumnMetrics( context, - entries.map((e) => MapEntry(_labelFor(e.key), e.value)).toList(), + [ for (final e in entries) MapEntry(_labelFor(e.key), e.value) ], ), ); } diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index e47b127f..4aef0e94 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -119,8 +119,9 @@ class _QueryaDropdownState extends material.State> { } _cachedMenuItems = List>.from(widget.items); _cachedMenuValue = widget.value; - _cachedMenuChildren = - widget.items.map((item) => _menuItem(item, cs)).toList(); + _cachedMenuChildren = [ + for (final item in widget.items) _menuItem(item, cs), + ]; return _cachedMenuChildren!; } From b3d6d694415c33347af517ab9487a72d46d12c1f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 11:01:24 +0300 Subject: [PATCH 36/44] perf: replace empty setState calls with scoped ListenableBuilder and explicit state mutations Resolves #531 --- lib/core/sdui/sdui_form_builder.dart | 5 +- .../connections/new_folder_dialog.dart | 14 ++-- .../extensions/extension_stats_view.dart | 2 +- .../mongodb/mongo_database_dialog.dart | 18 ++--- lib/features/mysql/mysql_stats_view.dart | 2 +- .../postgresql/postgres_stats_view.dart | 2 +- lib/features/redis/redis_explorer_view.dart | 7 +- lib/features/redis/redis_view.dart | 2 +- .../preferences_appearance_section.dart | 27 ++----- .../settings/preferences_controls.dart | 4 +- .../settings/theme_picker_button.dart | 8 +- .../updater/update_available_badge.dart | 12 ++- lib/shared/widgets/querya_tab_strip.dart | 76 ++++++------------- 13 files changed, 72 insertions(+), 107 deletions(-) diff --git a/lib/core/sdui/sdui_form_builder.dart b/lib/core/sdui/sdui_form_builder.dart index 0eeb1d89..5da85b55 100644 --- a/lib/core/sdui/sdui_form_builder.dart +++ b/lib/core/sdui/sdui_form_builder.dart @@ -137,9 +137,10 @@ class SduiFormBuilderState extends material.State { final path = picker != null ? await picker(field) : (await openFile())?.path; if (path == null || !mounted) return; - _textControllers[field.id]?.text = path; + setState(() { + _textControllers[field.id]?.text = path; + }); _notifyChanged(); - setState(() {}); } @override diff --git a/lib/features/connections/new_folder_dialog.dart b/lib/features/connections/new_folder_dialog.dart index 37c37539..513e7d26 100644 --- a/lib/features/connections/new_folder_dialog.dart +++ b/lib/features/connections/new_folder_dialog.dart @@ -80,7 +80,6 @@ class _NewFolderDialogContentState child: TextField( controller: _nameController, placeholder: const Text('Folder name'), - onChanged: (_) => setState(() {}), ), ), ], @@ -106,11 +105,14 @@ class _NewFolderDialogContentState child: const Text('Cancel'), ), const material.SizedBox(width: 12), - PrimaryButton( - onPressed: _name.isEmpty - ? null - : () => material.Navigator.of(context).pop(_name), - child: const Text('Create'), + ListenableBuilder( + listenable: _nameController, + builder: (context, _) => PrimaryButton( + onPressed: _name.isEmpty + ? null + : () => material.Navigator.of(context).pop(_name), + child: const Text('Create'), + ), ), ], ), diff --git a/lib/features/extensions/extension_stats_view.dart b/lib/features/extensions/extension_stats_view.dart index f7788a59..ed07fa7b 100644 --- a/lib/features/extensions/extension_stats_view.dart +++ b/lib/features/extensions/extension_stats_view.dart @@ -104,7 +104,7 @@ class _ExtensionStatsViewState extends material.State { .getServerStats(widget.connectionRow); if (!mounted) return; if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() {}); + setState(() => _stats = stats); } catch (_) {} } diff --git a/lib/features/mongodb/mongo_database_dialog.dart b/lib/features/mongodb/mongo_database_dialog.dart index 2bb39f8e..567f9af7 100644 --- a/lib/features/mongodb/mongo_database_dialog.dart +++ b/lib/features/mongodb/mongo_database_dialog.dart @@ -26,14 +26,6 @@ class _CreateMongoDBDialogContentState extends material.State<_CreateMongoDBDialogContent> { final _nameController = material.TextEditingController(); - @override - void initState() { - super.initState(); - _nameController.addListener(_onFieldChanged); - } - - void _onFieldChanged() => setState(() {}); - bool get _formValid => _nameController.text.trim().isNotEmpty; void _save() { @@ -43,7 +35,6 @@ class _CreateMongoDBDialogContentState @override void dispose() { - _nameController.removeListener(_onFieldChanged); _nameController.dispose(); super.dispose(); } @@ -106,9 +97,12 @@ class _CreateMongoDBDialogContentState child: const Text('Cancel'), ), const Gap(12), - PrimaryButton( - onPressed: _formValid ? _save : null, - child: const Text('Create'), + ListenableBuilder( + listenable: _nameController, + builder: (context, _) => PrimaryButton( + onPressed: _formValid ? _save : null, + child: const Text('Create'), + ), ), ], ), diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index d281bb0c..1c6c73a0 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -126,7 +126,7 @@ class _MysqlStatsViewState extends material.State { final stats = await conn.serverStats(); if (!mounted) return; if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() {}); + setState(() => _stats = stats); } catch (_) {} } diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 48cd1988..08c3d733 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -143,7 +143,7 @@ class _PostgresStatsViewState extends material.State { final stats = await c.serverStats(); if (!mounted) return; if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() {}); + setState(() => _stats = stats); } catch (_) {} } diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 48665b49..af037daf 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -45,6 +45,7 @@ class _RedisExplorerViewState extends material.State { // View mode bool _showStats = false; + int _refreshEpoch = 0; // Navigation state String? _selectedKey; @@ -224,7 +225,7 @@ class _RedisExplorerViewState extends material.State { _BreadcrumbBar( crumbs: _crumbs, onCrumbTap: _onCrumbTap, - onRefresh: () => setState(() {}), + onRefresh: () => setState(() => _refreshEpoch++), onStats: () => setState(() => _showStats = true), ), const Divider(height: 1), @@ -238,7 +239,7 @@ class _RedisExplorerViewState extends material.State { // Key editor if (_selectedKey != null) { return RedisKeyEditor( - key: ValueKey('key_${widget.database}_$_selectedKey'), + key: ValueKey('key_${widget.database}_${_selectedKey}_$_refreshEpoch'), connection: conn, database: widget.database, keyName: _selectedKey!, @@ -250,7 +251,7 @@ class _RedisExplorerViewState extends material.State { // Keys list return RedisKeysView( - key: ValueKey('keys_${widget.database}'), + key: ValueKey('keys_${widget.database}_$_refreshEpoch'), connection: conn, database: widget.database, onKeyTap: _navigateToKey, diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 4ffa5d0a..d78a5619 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -156,7 +156,7 @@ class _RedisViewState extends material.State { final info = parseRedisInfo(raw); if (!mounted) return; if (!replaceIfChanged(_info, info, (v) => _info = v)) return; - setState(() {}); + setState(() => _info = info); } catch (_) {} } diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 1c0d93b4..89ccc922 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -35,22 +35,6 @@ class _PreferencesAppearanceSectionState bool _installingFromUrl = false; bool _openingThemesFolder = false; - @override - void initState() { - super.initState(); - _controller.addListener(_onThemeChanged); - } - - @override - void dispose() { - _controller.removeListener(_onThemeChanged); - super.dispose(); - } - - void _onThemeChanged() { - if (mounted) setState(() {}); - } - Future _setThemeMode(ThemeMode mode) async { await _controller.setThemeMode(mode); } @@ -160,9 +144,12 @@ class _PreferencesAppearanceSectionState @override material.Widget build(material.BuildContext context) { - final c = _controller; - final themes = c.availableThemes; - final refreshingThemes = c.isLoadingAvailableThemes; + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final c = _controller; + final themes = c.availableThemes; + final refreshingThemes = c.isLoadingAvailableThemes; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, @@ -364,5 +351,7 @@ class _PreferencesAppearanceSectionState ), ], ); + }, +); } } diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index a897eaa2..61817ed1 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -205,7 +205,9 @@ class _InterfaceScaleSliderState extends material.State { void _onCommittedScaleChanged() { if (_dragScale != null || !mounted) return; - setState(() {}); + setState(() { + _dragScale = null; + }); } bool _onKeyEvent(KeyEvent event) { diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index 01b8ceac..30cdd59f 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -174,8 +174,12 @@ class _ThemePickerButtonState extends material.State { }); } + String _searchQuery = ''; + void _onSearchChanged() { - setState(() {}); + setState(() { + _searchQuery = _searchController.text; + }); if (_scrollController.hasClients) { _scrollController.jumpTo(0); } @@ -187,7 +191,7 @@ class _ThemePickerButtonState extends material.State { } List get _filteredThemes => - filterThemeDefinitions(widget.themes, _searchController.text); + filterThemeDefinitions(widget.themes, _searchQuery); bool get _enabled => !widget.isLoading; diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart index 832de9b8..d776225b 100644 --- a/lib/features/updater/update_available_badge.dart +++ b/lib/features/updater/update_available_badge.dart @@ -63,7 +63,6 @@ class UpdateAvailableBadgeState extends material.State void _onControllerChanged() { if (!mounted) return; - setState(() {}); _syncPulse(); } @@ -100,9 +99,12 @@ class UpdateAvailableBadgeState extends material.State @override material.Widget build(material.BuildContext context) { - if (!widget.controller.showBadge) { - return const material.SizedBox.shrink(); - } + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + if (!widget.controller.showBadge) { + return const material.SizedBox.shrink(); + } final version = widget.controller.pendingUpdate?.version ?? ''; final wb = context.workbench; @@ -160,5 +162,7 @@ class UpdateAvailableBadgeState extends material.State ), ), ); + }, +); } } diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index d91709b4..688d35bb 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -251,7 +251,7 @@ class _QueryaTabStripState extends material.State } /// Sliding pill; listens to springs so the tab [Row] is not rebuilt per tick. -class _TabStripIndicator extends material.StatefulWidget { +class _TabStripIndicator extends material.StatelessWidget { const _TabStripIndicator({ required this.left, required this.width, @@ -262,63 +262,31 @@ class _TabStripIndicator extends material.StatefulWidget { final QueryaSpringController width; final material.Color color; - @override - material.State<_TabStripIndicator> createState() => - _TabStripIndicatorState(); -} - -class _TabStripIndicatorState extends material.State<_TabStripIndicator> { - @override - void initState() { - super.initState(); - widget.left.addListener(_onTick); - widget.width.addListener(_onTick); - } - - @override - void didUpdateWidget(covariant _TabStripIndicator oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.left != widget.left) { - oldWidget.left.removeListener(_onTick); - widget.left.addListener(_onTick); - } - if (oldWidget.width != widget.width) { - oldWidget.width.removeListener(_onTick); - widget.width.addListener(_onTick); - } - } - - @override - void dispose() { - widget.left.removeListener(_onTick); - widget.width.removeListener(_onTick); - super.dispose(); - } - - void _onTick() { - if (mounted) setState(() {}); - } - @override material.Widget build(material.BuildContext context) { - final w = widget.width.value; - if (w <= 0) return const material.SizedBox.shrink(); - return material.Positioned( - key: const material.ValueKey('querya_tab_indicator'), - left: widget.left.value, - width: w, - top: 0, - bottom: 0, - child: material.RepaintBoundary( - child: material.IgnorePointer( - child: material.DecoratedBox( - decoration: material.BoxDecoration( - color: widget.color, - borderRadius: material.BorderRadius.circular(6), + return ListenableBuilder( + listenable: Listenable.merge([left, width]), + builder: (context, _) { + final w = width.value; + if (w <= 0) return const material.SizedBox.shrink(); + return material.Positioned( + key: const material.ValueKey('querya_tab_indicator'), + left: left.value, + width: w, + top: 0, + bottom: 0, + child: material.RepaintBoundary( + child: material.IgnorePointer( + child: material.DecoratedBox( + decoration: material.BoxDecoration( + color: color, + borderRadius: material.BorderRadius.circular(6), + ), + ), ), ), - ), - ), + ); + }, ); } } From b66eb3be5c4926801b611a79d672103434cb47e9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 11:13:13 +0300 Subject: [PATCH 37/44] refactor: add context.theme and context.colors BuildContext extensions to streamline theme boilerplate Resolves #532 --- lib/core/theme/querya_theme_scope.dart | 10 +++++++++- .../connections/connections_panel_sidebar.dart | 2 +- lib/features/connections/driver_icon.dart | 4 ++-- lib/features/connections/driver_manager_dialog.dart | 2 +- .../connections/extension_connection_form.dart | 2 +- lib/features/connections/new_connection_dialog.dart | 2 +- .../connections/new_connection_url_dialog.dart | 2 +- lib/features/connections/new_folder_dialog.dart | 2 +- lib/features/connections/sqlite_connection_form.dart | 2 +- lib/features/mongodb/mongo_database_dialog.dart | 2 +- lib/shared/widgets/widgets.dart | 2 ++ 11 files changed, 21 insertions(+), 11 deletions(-) diff --git a/lib/core/theme/querya_theme_scope.dart b/lib/core/theme/querya_theme_scope.dart index 2da177d8..ddafa900 100644 --- a/lib/core/theme/querya_theme_scope.dart +++ b/lib/core/theme/querya_theme_scope.dart @@ -1,4 +1,6 @@ +import 'package:flutter/material.dart' as material; import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; import 'querya_editor_theme.dart'; import 'querya_semantic_palette.dart'; @@ -30,8 +32,14 @@ class QueryaThemeScope extends InheritedWidget { bool updateShouldNotify(QueryaThemeScope oldWidget) => data != oldWidget.data; } -/// Convenient access to [QueryaTheme] tokens from [BuildContext]. +/// Convenient access to [QueryaTheme] tokens and [Theme] / [ColorScheme] from [BuildContext]. extension QueryaThemeContext on BuildContext { + /// Shortcut for Material [material.Theme.of]. + material.ThemeData get theme => material.Theme.of(this); + + /// Shortcut for [shadcn.ColorScheme] from [shadcn.Theme.of]. + shadcn.ColorScheme get colors => shadcn.Theme.of(this).colorScheme; + QueryaTheme get queryaTheme => QueryaThemeScope.of(this); QueryaWorkbenchTheme get workbench => queryaTheme.workbench; diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 4540fe66..7323f44f 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -6,7 +6,7 @@ material.Widget _sidebarConnectionShell({ required material.VoidCallback? onTap, required material.Widget child, }) { - final p = Theme.of(context).colorScheme.primary; + final p = context.colors.primary; return material.Material( color: material.Colors.transparent, child: material.InkWell( diff --git a/lib/features/connections/driver_icon.dart b/lib/features/connections/driver_icon.dart index 6aa3277b..2f1b953f 100644 --- a/lib/features/connections/driver_icon.dart +++ b/lib/features/connections/driver_icon.dart @@ -24,7 +24,7 @@ class DriverIcon extends StatelessWidget { final fallback = material.Icon( fallbackIcon, size: size, - color: Theme.of(context).colorScheme.primary, + color: context.colors.primary, ); if (filePath != null) { @@ -64,7 +64,7 @@ class DriverIconImage extends StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); + final theme = context.theme; final fallback = material.Icon( fallbackIcon, size: size, diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index a32fd6db..a52e780c 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -74,7 +74,7 @@ class _DriverManagerDialogContent extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; final drivers = _buildDriverList(); return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index 13845d83..bdeb803b 100644 --- a/lib/features/connections/extension_connection_form.dart +++ b/lib/features/connections/extension_connection_form.dart @@ -189,7 +189,7 @@ class _ExtensionConnectionFormContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; final title = _isEditing ? 'Edit ${widget.driver.displayName}' : widget.driver.displayName; diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index b64f2470..0671338d 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -104,7 +104,7 @@ class _NewConnectionDialogContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(context); final dialogH = WindowLayout.newConnectionDialogHeight(context); final headerPadH = dialogMaxW < 420 ? 16.0 : 24.0; diff --git a/lib/features/connections/new_connection_url_dialog.dart b/lib/features/connections/new_connection_url_dialog.dart index b4c67678..1d359f18 100644 --- a/lib/features/connections/new_connection_url_dialog.dart +++ b/lib/features/connections/new_connection_url_dialog.dart @@ -48,7 +48,7 @@ class _NewConnectionUrlDialogContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, diff --git a/lib/features/connections/new_folder_dialog.dart b/lib/features/connections/new_folder_dialog.dart index 513e7d26..1fe51667 100644 --- a/lib/features/connections/new_folder_dialog.dart +++ b/lib/features/connections/new_folder_dialog.dart @@ -36,7 +36,7 @@ class _NewFolderDialogContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index 5185f211..c0f96817 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -169,7 +169,7 @@ class _SqliteConnectionFormContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(context); final dialogH = WindowLayout.newConnectionDialogHeight(context); final scrollH = dialogH - 120.0; // Subtract header and footer heights diff --git a/lib/features/mongodb/mongo_database_dialog.dart b/lib/features/mongodb/mongo_database_dialog.dart index 567f9af7..8973c2c3 100644 --- a/lib/features/mongodb/mongo_database_dialog.dart +++ b/lib/features/mongodb/mongo_database_dialog.dart @@ -41,7 +41,7 @@ class _CreateMongoDBDialogContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; return QueryaDialogCard( constraints: WindowLayout.dialogConstraints(context, maxWidth: 500), diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 49ae9541..10890c12 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -20,4 +20,6 @@ export 'querya_dropdown.dart' QueryaDropdownTokens, kPreferencesLabelWidth; export 'tree_load_error.dart'; +export 'package:querya_desktop/core/theme/querya_theme_scope.dart' + show QueryaThemeContext; export 'package:shadcn_flutter/shadcn_flutter.dart'; From f4bc138a0cfe0093b81b1e62ad0cab90030116ad Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 11:19:38 +0300 Subject: [PATCH 38/44] chore: remove redundant querya_theme_scope imports now re-exported by shared widgets --- lib/features/extensions/extension_sql_workspace.dart | 1 - lib/features/help/about_dialog.dart | 1 - lib/features/main_screen/main_screen.dart | 2 -- lib/features/main_screen/workspace_empty_hero.dart | 1 - lib/features/mongodb/mongo_collections_view.dart | 1 - lib/features/mongodb/mongo_databases_view.dart | 1 - lib/features/mongodb/mongo_document_editor.dart | 1 - lib/features/mongodb/mongo_documents_view.dart | 1 - lib/features/mysql/mysql_sql_workspace.dart | 1 - lib/features/postgresql/postgres_sql_workspace.dart | 1 - lib/features/redis/redis_key_editor.dart | 1 - lib/features/redis/redis_keys_view.dart | 1 - lib/features/sqlite/sqlite_sql_workspace.dart | 1 - lib/features/updater/update_dialog.dart | 1 - 14 files changed, 15 deletions(-) diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index 312db97d..2be7c719 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -8,7 +8,6 @@ import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; diff --git a/lib/features/help/about_dialog.dart b/lib/features/help/about_dialog.dart index 8735e213..04054392 100644 --- a/lib/features/help/about_dialog.dart +++ b/lib/features/help/about_dialog.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart' as material; import 'package:package_info_plus/package_info_plus.dart'; import 'package:querya_desktop/core/app/external_link.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows the About Querya dialog. diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index f24f201e..f3f5a2f4 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -11,8 +11,6 @@ import 'package:querya_desktop/core/layout/querya_split_handle.dart'; import 'package:querya_desktop/core/motion/querya_spring.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:querya_desktop/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart'; import 'package:querya_desktop/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index d254bd5e..06252381 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -6,7 +6,6 @@ import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_stagger.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 7ed1903f..8e6a9168 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -3,7 +3,6 @@ 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'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/mongodb/mongo_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index 4c88374b..20e7686d 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -2,7 +2,6 @@ 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/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index bba3ba4d..6cc9dafa 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -5,7 +5,6 @@ import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index a7319e89..f75cc028 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -3,7 +3,6 @@ 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/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 5ece7c49..5bf34a27 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -11,7 +11,6 @@ import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index f511f59c..2efe818b 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -13,7 +13,6 @@ import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index 5a46f646..5a0d1415 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 071827d3..364402ce 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/theme/querya_semantic_palette.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 0ecab186..f8dd7eb9 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -11,7 +11,6 @@ import 'package:querya_desktop/core/database/sql_limit.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index 6600fb91..4103b4d2 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/updater/app_updater_service.dart'; import 'package:querya_desktop/core/updater/update_manifest.dart'; import 'package:querya_desktop/features/updater/update_changelog_view.dart'; From 4bed24cc6934138c76bbcb2beb2ae7bf1d4ee3df Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 11:26:47 +0300 Subject: [PATCH 39/44] perf: replace .length > 0 with .isNotEmpty on iterables Resolves #533 --- lib/core/extensions/rpc/json_rpc_payload_limits.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/core/extensions/rpc/json_rpc_payload_limits.dart b/lib/core/extensions/rpc/json_rpc_payload_limits.dart index 72750292..274dcd7b 100644 --- a/lib/core/extensions/rpc/json_rpc_payload_limits.dart +++ b/lib/core/extensions/rpc/json_rpc_payload_limits.dart @@ -91,7 +91,7 @@ class _BoundedUtf8LineSplitter }, onError: fail, onDone: () { - if (pending.length > 0) { + if (pending.isNotEmpty) { if (pending.length > maxLineBytes) { fail( JsonRpcPayloadTooLargeException( From 5c5ae4a6ecac10222dc7cef2bce2f76801f29b2b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 11:42:17 +0300 Subject: [PATCH 40/44] perf: offload heavy JSON decoding to isolate Resolves #538 --- lib/core/market/http_marketplace_repository.dart | 7 +++++-- lib/core/updater/github_releases_client.dart | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index e49694fb..0321021e 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'package:archive/archive.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; @@ -72,7 +73,8 @@ class HttpMarketplaceRepository implements MarketplaceRepository { if (response.statusCode != 200) { throw MarketplaceException('Failed to load trending extensions (HTTP ${response.statusCode})'); } - final List data = jsonDecode(response.body) as List; + final body = response.body; + final List data = await Isolate.run(() => jsonDecode(body)) as List; return data.map((json) => ExtensionManifest.fromJson(json as Map)).toList(); } @@ -89,7 +91,8 @@ class HttpMarketplaceRepository implements MarketplaceRepository { if (response.statusCode != 200) { throw MarketplaceException('Search failed (HTTP ${response.statusCode})'); } - final List data = jsonDecode(response.body) as List; + final body = response.body; + final List data = await Isolate.run(() => jsonDecode(body)) as List; return data.map((json) => ExtensionManifest.fromJson(json as Map)).toList(); } diff --git a/lib/core/updater/github_releases_client.dart b/lib/core/updater/github_releases_client.dart index a22fed25..d262040e 100644 --- a/lib/core/updater/github_releases_client.dart +++ b/lib/core/updater/github_releases_client.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:isolate'; import 'package:http/http.dart' as http; @@ -28,7 +29,8 @@ class GitHubReleasesClient { 'GitHub Releases API returned HTTP ${response.statusCode}', ); } - final decoded = jsonDecode(response.body); + final body = response.body; + final decoded = await Isolate.run(() => jsonDecode(body)); if (decoded is! Map) { throw const GitHubReleasesException('Unexpected GitHub Releases payload'); } @@ -41,7 +43,8 @@ class GitHubReleasesClient { 'GitHub Releases API returned HTTP ${response.statusCode}', ); } - final decoded = jsonDecode(response.body); + final body = response.body; + final decoded = await Isolate.run(() => jsonDecode(body)); if (decoded is! List) { throw const GitHubReleasesException('Unexpected GitHub Releases list payload'); } From 1e71eb27dee3476856b1204d476a01405919a7c3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 11:47:30 +0300 Subject: [PATCH 41/44] perf: use cacheWidth/cacheHeight for image downsampling Resolves #539 --- lib/features/connections/connections_panel_extension.dart | 2 ++ lib/features/connections/connections_panel_mongo.dart | 2 ++ lib/features/connections/connections_panel_mysql.dart | 2 ++ .../connections/connections_panel_postgres_connection.dart | 2 ++ lib/features/connections/connections_panel_redis.dart | 2 ++ lib/features/connections/connections_panel_sidebar.dart | 2 ++ lib/features/connections/connections_panel_sqlite.dart | 2 ++ lib/features/connections/driver_icon.dart | 6 ++++++ lib/features/mysql/mysql_connection_form.dart | 2 ++ lib/features/mysql/mysql_stats_view.dart | 4 +++- lib/features/postgresql/postgres_stats_view.dart | 2 ++ lib/features/postgresql/postgresql_connection_form.dart | 2 ++ 12 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index f70afaa4..8f06118a 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -151,6 +151,8 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { widget.iconAsset!, width: QueryaIconSizes.sidebarConnectionIcon, height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index 2ee9b6a0..4db6c556 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -146,6 +146,8 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { widget.iconAsset!, width: QueryaIconSizes.sidebarConnectionIcon, height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index abe2b92d..d8673c16 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -111,6 +111,8 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { widget.iconAsset!, width: QueryaIconSizes.sidebarConnectionIcon, height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 5a50066f..87084347 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -114,6 +114,8 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { widget.iconAsset!, width: QueryaIconSizes.sidebarConnectionIcon, height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 6e6f509f..0ea0f9c1 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -129,6 +129,8 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { widget.iconAsset!, width: QueryaIconSizes.sidebarConnectionIcon, height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 7323f44f..5b74129a 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -95,6 +95,8 @@ class _ConnectionTile extends StatelessWidget { iconAsset!, width: QueryaIconSizes.sidebarConnectionIcon, height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( icon, diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 6be778e4..d783df0f 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -115,6 +115,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { widget.iconAsset!, width: QueryaIconSizes.sidebarConnectionIcon, height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, diff --git a/lib/features/connections/driver_icon.dart b/lib/features/connections/driver_icon.dart index 2f1b953f..8621793b 100644 --- a/lib/features/connections/driver_icon.dart +++ b/lib/features/connections/driver_icon.dart @@ -35,10 +35,13 @@ class DriverIcon extends StatelessWidget { ); } if (assetPath != null) { + final cacheSize = (size * MediaQuery.devicePixelRatioOf(context)).toInt(); return material.Image.asset( assetPath!, width: size, height: size, + cacheWidth: cacheSize, + cacheHeight: cacheSize, fit: material.BoxFit.contain, filterQuality: material.FilterQuality.medium, errorBuilder: (_, __, ___) => fallback, @@ -83,10 +86,13 @@ class DriverIconImage extends StatelessWidget { errorBuilder: (_, __, ___) => fallback, ); } + final cacheSize = (size * MediaQuery.devicePixelRatioOf(context)).toInt(); return material.Image.file( file, width: size, height: size, + cacheWidth: cacheSize, + cacheHeight: cacheSize, fit: material.BoxFit.contain, filterQuality: material.FilterQuality.medium, errorBuilder: (_, __, ___) => fallback, diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index de323651..47f573f9 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -318,6 +318,8 @@ class _MysqlConnectionFormContentState height: 24, child: material.Image.asset( 'assets/images/mysql_icon.png', + cacheWidth: (40 * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (40 * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( material.Icons.table_chart_rounded, diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index 1c6c73a0..01d4d0a2 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -147,7 +147,7 @@ class _MysqlStatsViewState extends material.State { ); final cs = Theme.of(context).colorScheme; - final width = material.MediaQuery.sizeOf(context).width; + final width = MediaQuery.sizeOf(context).width; if (_loading) { return material.Center( @@ -254,6 +254,8 @@ class _MysqlStatsViewState extends material.State { height: 28, child: material.Image.asset( 'assets/images/mysql_icon.png', + cacheWidth: (28 * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (28 * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( material.Icons.storage_rounded, diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 08c3d733..ab73ce50 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -276,6 +276,8 @@ class _PostgresStatsViewState extends material.State { height: 28, child: material.Image.asset( 'assets/images/postgresql_icon.png', + cacheWidth: (32 * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (32 * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( material.Icons.storage_rounded, diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index b66d9e28..4442e66d 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -436,6 +436,8 @@ class _PostgresConnectionFormContentState height: 24, child: material.Image.asset( 'assets/images/postgresql_icon.png', + cacheWidth: (40 * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (40 * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( material.Icons.storage_rounded, From 43b4c6d1166dbbff31801c3eb236b5ecb19b91cf Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 12:26:29 +0300 Subject: [PATCH 42/44] chore: update flutter SDK constraints and major dependencies - Bump SDK bounds to >=3.12.2 - Update fl_chart, flutter_secure_storage, package_info_plus, flutter_lints to latest major versions - Use `scrollCacheExtent` (Flutter 3.41+ requirement) - Suppress new noisy lints from flutter_lints 6.0 --- analysis_options.yaml | 3 +++ lib/features/connections/driver_manager_dialog.dart | 2 +- .../presentation/pages/extension_manager_dialog.dart | 4 ++-- .../main_screen/sql_query_history_dialog.dart | 2 +- lib/features/mongodb/mongo_collections_view.dart | 5 +++-- lib/features/mongodb/mongo_documents_view.dart | 5 +++-- lib/features/postgresql/postgres_browser_views.dart | 11 ++++++----- lib/features/redis/redis_key_editor.dart | 8 ++++---- lib/features/redis/redis_keys_view.dart | 3 ++- lib/features/updater/update_dialog.dart | 2 +- macos/Flutter/GeneratedPluginRegistrant.swift | 4 ++-- pubspec.yaml | 11 ++++++----- 12 files changed, 34 insertions(+), 26 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index b526cc23..280bae0c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,3 +9,6 @@ linter: rules: - prefer_const_constructors - prefer_const_declarations + unnecessary_underscores: false + prefer_initializing_formals: false + use_null_aware_elements: false diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index a52e780c..f4b45fe3 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -115,7 +115,7 @@ class _DriverManagerDialogContent extends material.StatelessWidget { shrinkWrap: true, padding: const material.EdgeInsets.symmetric(vertical: 8), itemCount: drivers.length, - separatorBuilder: (_, __) => material.Divider( + separatorBuilder: (_, _) => material.Divider( height: 1, color: theme.border.withValues(alpha: 0.3), ), diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index a2f0a275..67b99aba 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -283,7 +283,7 @@ class _ExtensionManagerContentState : material.ListView.separated( padding: const material.EdgeInsets.all(24), itemCount: _installed.length, - separatorBuilder: (_, __) => + separatorBuilder: (_, _) => const material.SizedBox(height: 16), itemBuilder: (ctx, i) { final manifest = _installed[i]; @@ -369,7 +369,7 @@ class _ExtensionManagerContentState : material.ListView.separated( padding: const material.EdgeInsets.all(24), itemCount: _marketplace.length, - separatorBuilder: (_, __) => + separatorBuilder: (_, _) => const material.SizedBox(height: 16), itemBuilder: (ctx, i) { final manifest = _marketplace[i]; diff --git a/lib/features/main_screen/sql_query_history_dialog.dart b/lib/features/main_screen/sql_query_history_dialog.dart index 123cdf71..79e143ce 100644 --- a/lib/features/main_screen/sql_query_history_dialog.dart +++ b/lib/features/main_screen/sql_query_history_dialog.dart @@ -192,7 +192,7 @@ class _SqlQueryHistoryDialogContentState vertical: 4, ), itemCount: items.length, - separatorBuilder: (_, __) => + separatorBuilder: (_, _) => material.Divider(height: 1, color: scheme.border), itemBuilder: (context, i) { final e = items[i]; diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 8e6a9168..3b8b2827 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -1,6 +1,7 @@ import 'dart:math' show min; import 'package:flutter/material.dart' as material; +import 'package:flutter/rendering.dart'; 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'; @@ -332,9 +333,9 @@ class _MongoCollectionsViewState extends material.State { child: const Text('No collections found').muted(), ) : material.ListView.separated( - cacheExtent: 400, + scrollCacheExtent: const ScrollCacheExtent.pixels(400), itemCount: _collections.length, - separatorBuilder: (_, __) => Divider( + separatorBuilder: (_, _) => Divider( height: 1, color: cs.border.withValues(alpha: 0.15), ), diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index f75cc028..d022f29d 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart' as material; +import 'package:flutter/rendering.dart'; 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'; @@ -220,9 +221,9 @@ class _MongoDocumentsViewState extends material.State { ) : material.ListView.separated( padding: const material.EdgeInsets.all(16), - cacheExtent: 400, + scrollCacheExtent: const ScrollCacheExtent.pixels(400), itemCount: _documents.length, - separatorBuilder: (_, __) => const Gap(8), + separatorBuilder: (_, _) => const Gap(8), itemBuilder: (context, i) { final shadcnCs = shadcn.Theme.of(context).colorScheme; return _DocumentCard( diff --git a/lib/features/postgresql/postgres_browser_views.dart b/lib/features/postgresql/postgres_browser_views.dart index 4d67d575..e75b9cf2 100644 --- a/lib/features/postgresql/postgres_browser_views.dart +++ b/lib/features/postgresql/postgres_browser_views.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:flutter/rendering.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_metadata.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -129,7 +130,7 @@ class _PostgresIndexListViewState child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - cacheExtent: 400, + scrollCacheExtent: const ScrollCacheExtent.pixels(400), itemCount: _rows.isEmpty ? 1 : _rows.length, itemBuilder: (context, i) { if (_rows.isEmpty) { @@ -309,7 +310,7 @@ class _PostgresTriggerListViewState child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - cacheExtent: 400, + scrollCacheExtent: const ScrollCacheExtent.pixels(400), itemCount: _rows.isEmpty ? 1 : _rows.length, itemBuilder: (context, i) { if (_rows.isEmpty) { @@ -473,7 +474,7 @@ class _PostgresTypeListViewState extends material.State { child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - cacheExtent: 400, + scrollCacheExtent: const ScrollCacheExtent.pixels(400), itemCount: _rows.isEmpty ? 1 : _rows.length, itemBuilder: (context, i) { if (_rows.isEmpty) { @@ -625,7 +626,7 @@ class _PostgresExtensionListViewState child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - cacheExtent: 400, + scrollCacheExtent: const ScrollCacheExtent.pixels(400), itemCount: _rows.isEmpty ? 1 : _rows.length, itemBuilder: (context, i) { if (_rows.isEmpty) { @@ -781,7 +782,7 @@ class _PostgresFdwListViewState extends material.State { child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - cacheExtent: 400, + scrollCacheExtent: const ScrollCacheExtent.pixels(400), itemCount: totalItems, itemBuilder: (context, i) { if (i == 0) { diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index 5a0d1415..becf5676 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -554,7 +554,7 @@ class _RedisKeyEditorState extends material.State { ? material.Center(child: const Text('No fields').muted()) : material.ListView.separated( itemCount: entries.length, - separatorBuilder: (_, __) => const Gap(4), + separatorBuilder: (_, _) => const Gap(4), itemBuilder: (context, index) { final entry = entries[index]; return _FieldRow( @@ -607,7 +607,7 @@ class _RedisKeyEditorState extends material.State { ? material.Center(child: const Text('No items').muted()) : material.ListView.separated( itemCount: _listValue.length, - separatorBuilder: (_, __) => const Gap(4), + separatorBuilder: (_, _) => const Gap(4), itemBuilder: (context, i) => _IndexedValueRow( index: i, value: _listValue[i], @@ -656,7 +656,7 @@ class _RedisKeyEditorState extends material.State { ? material.Center(child: const Text('No members').muted()) : material.ListView.separated( itemCount: _setValue.length, - separatorBuilder: (_, __) => const Gap(4), + separatorBuilder: (_, _) => const Gap(4), itemBuilder: (context, index) => _MemberRow( member: _setValue[index], onDelete: () => _setRemove(_setValue[index]), @@ -715,7 +715,7 @@ class _RedisKeyEditorState extends material.State { ? material.Center(child: const Text('No members').muted()) : material.ListView.separated( itemCount: _zsetValue.length, - separatorBuilder: (_, __) => const Gap(4), + separatorBuilder: (_, _) => const Gap(4), itemBuilder: (context, index) { final (member, score) = _zsetValue[index]; return _ScoredMemberRow( diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 364402ce..11444812 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:flutter/rendering.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/theme/querya_semantic_palette.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -186,7 +187,7 @@ class _RedisKeysViewState extends material.State { ) : material.ListView.builder( padding: const material.EdgeInsets.all(16), - cacheExtent: 400, + scrollCacheExtent: const ScrollCacheExtent.pixels(400), itemCount: _keys.length + (_hasMore ? 1 : 0), itemBuilder: (context, i) { final shadcnCs = shadcn.Theme.of(context).colorScheme; diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index 4103b4d2..38183fc8 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -276,7 +276,7 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { alignment: material.Alignment.topCenter, children: [ ...previousChildren, - if (currentChild != null) currentChild, + ?currentChild, ], ); }, diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ff1df342..4d44b1bc 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,7 +8,7 @@ import Foundation import bitsdojo_window_macos import device_info_plus import file_selector_macos -import flutter_secure_storage_macos +import flutter_secure_storage_darwin import irondash_engine_context import package_info_plus import refresh_rate @@ -20,7 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RefreshRatePlugin.register(with: registry.registrar(forPlugin: "RefreshRatePlugin")) diff --git a/pubspec.yaml b/pubspec.yaml index 2bfd7caa..0e24245e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ version: 0.4.12+1 environment: - sdk: ^3.5.0 + sdk: '>=3.12.2 <4.0.0' dependencies: flutter: @@ -18,25 +18,26 @@ dependencies: bitsdojo_window: ^0.1.6 path: ^1.9.0 path_provider: ^2.1.5 + sqflite: ^2.3.2 sqflite_common_ffi: ^2.3.2 mongo_dart: ^0.10.8 redis: ^4.0.0 postgres: ^3.5.6 - fl_chart: ^0.69.0 + fl_chart: ^1.2.0 mysql_client: ^0.0.27 - flutter_secure_storage: ^9.2.4 + flutter_secure_storage: ^10.3.1 file_selector: ^1.1.0 syntax_highlight: ^0.5.0 archive: ^4.0.9 url_launcher: ^6.3.1 - package_info_plus: ^8.3.0 + package_info_plus: ^9.0.1 flutter_svg: ^2.3.0 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 # Used in tests to mock paths (path_provider has no plugin in flutter test). path_provider_platform_interface: ^2.1.2 From 7a6157a18148c3003ad9c26fd394103bd67b4497 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 12:34:24 +0300 Subject: [PATCH 43/44] fix: adjust SDK constraint to >=3.5.0 for compatibility with Dart 3.11.4 --- lib/app/app.dart | 6 +++--- lib/core/motion/querya_stagger.dart | 2 +- lib/features/connections/driver_manager_dialog.dart | 2 +- lib/features/connections/new_folder_dialog.dart | 2 +- .../presentation/pages/extension_manager_dialog.dart | 4 ++-- lib/features/main_screen/sql_query_history_dialog.dart | 2 +- lib/features/mongodb/mongo_collections_view.dart | 2 +- lib/features/mongodb/mongo_database_dialog.dart | 2 +- lib/features/mongodb/mongo_documents_view.dart | 2 +- lib/features/redis/redis_key_editor.dart | 8 ++++---- lib/features/settings/preferences_appearance_section.dart | 4 ++-- lib/features/updater/update_available_badge.dart | 2 +- lib/features/updater/update_dialog.dart | 2 +- lib/shared/widgets/querya_tab_strip.dart | 2 +- pubspec.yaml | 2 +- 15 files changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index 3fec8d08..51f10be5 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -21,17 +21,17 @@ class QueryaApp extends StatelessWidget { return ListenableBuilder( listenable: themeController, - builder: (context, _) { + builder: (context, __) { final queryaTheme = themeController.activeTheme; final colorScheme = queryaTheme.colorScheme; return ListenableBuilder( listenable: uiScaleController, - builder: (context, _) { + builder: (context, __) { final scale = uiScaleController.scale; return ListenableBuilder( listenable: motionController, - builder: (context, _) { + builder: (context, __) { final motionLevel = motionController.level; final disableAnimations = MediaQuery.maybeOf(context)?.disableAnimations ?? diff --git a/lib/core/motion/querya_stagger.dart b/lib/core/motion/querya_stagger.dart index 18735fc8..5f10c272 100644 --- a/lib/core/motion/querya_stagger.dart +++ b/lib/core/motion/querya_stagger.dart @@ -72,7 +72,7 @@ class _QueryaStaggerState extends State return AnimatedBuilder( animation: _controller, - builder: (context, _) { + builder: (context, __) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index f4b45fe3..a52e780c 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -115,7 +115,7 @@ class _DriverManagerDialogContent extends material.StatelessWidget { shrinkWrap: true, padding: const material.EdgeInsets.symmetric(vertical: 8), itemCount: drivers.length, - separatorBuilder: (_, _) => material.Divider( + separatorBuilder: (_, __) => material.Divider( height: 1, color: theme.border.withValues(alpha: 0.3), ), diff --git a/lib/features/connections/new_folder_dialog.dart b/lib/features/connections/new_folder_dialog.dart index 1fe51667..f7cc9ced 100644 --- a/lib/features/connections/new_folder_dialog.dart +++ b/lib/features/connections/new_folder_dialog.dart @@ -107,7 +107,7 @@ class _NewFolderDialogContentState const material.SizedBox(width: 12), ListenableBuilder( listenable: _nameController, - builder: (context, _) => PrimaryButton( + builder: (context, __) => PrimaryButton( onPressed: _name.isEmpty ? null : () => material.Navigator.of(context).pop(_name), diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 67b99aba..a2f0a275 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -283,7 +283,7 @@ class _ExtensionManagerContentState : material.ListView.separated( padding: const material.EdgeInsets.all(24), itemCount: _installed.length, - separatorBuilder: (_, _) => + separatorBuilder: (_, __) => const material.SizedBox(height: 16), itemBuilder: (ctx, i) { final manifest = _installed[i]; @@ -369,7 +369,7 @@ class _ExtensionManagerContentState : material.ListView.separated( padding: const material.EdgeInsets.all(24), itemCount: _marketplace.length, - separatorBuilder: (_, _) => + separatorBuilder: (_, __) => const material.SizedBox(height: 16), itemBuilder: (ctx, i) { final manifest = _marketplace[i]; diff --git a/lib/features/main_screen/sql_query_history_dialog.dart b/lib/features/main_screen/sql_query_history_dialog.dart index 79e143ce..123cdf71 100644 --- a/lib/features/main_screen/sql_query_history_dialog.dart +++ b/lib/features/main_screen/sql_query_history_dialog.dart @@ -192,7 +192,7 @@ class _SqlQueryHistoryDialogContentState vertical: 4, ), itemCount: items.length, - separatorBuilder: (_, _) => + separatorBuilder: (_, __) => material.Divider(height: 1, color: scheme.border), itemBuilder: (context, i) { final e = items[i]; diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 3b8b2827..33a0dbfc 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -335,7 +335,7 @@ class _MongoCollectionsViewState extends material.State { : material.ListView.separated( scrollCacheExtent: const ScrollCacheExtent.pixels(400), itemCount: _collections.length, - separatorBuilder: (_, _) => Divider( + separatorBuilder: (_, __) => Divider( height: 1, color: cs.border.withValues(alpha: 0.15), ), diff --git a/lib/features/mongodb/mongo_database_dialog.dart b/lib/features/mongodb/mongo_database_dialog.dart index 8973c2c3..7c9221c4 100644 --- a/lib/features/mongodb/mongo_database_dialog.dart +++ b/lib/features/mongodb/mongo_database_dialog.dart @@ -99,7 +99,7 @@ class _CreateMongoDBDialogContentState const Gap(12), ListenableBuilder( listenable: _nameController, - builder: (context, _) => PrimaryButton( + builder: (context, __) => PrimaryButton( onPressed: _formValid ? _save : null, child: const Text('Create'), ), diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index d022f29d..e746e0f0 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -223,7 +223,7 @@ class _MongoDocumentsViewState extends material.State { padding: const material.EdgeInsets.all(16), scrollCacheExtent: const ScrollCacheExtent.pixels(400), itemCount: _documents.length, - separatorBuilder: (_, _) => const Gap(8), + separatorBuilder: (_, __) => const Gap(8), itemBuilder: (context, i) { final shadcnCs = shadcn.Theme.of(context).colorScheme; return _DocumentCard( diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index becf5676..5a0d1415 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -554,7 +554,7 @@ class _RedisKeyEditorState extends material.State { ? material.Center(child: const Text('No fields').muted()) : material.ListView.separated( itemCount: entries.length, - separatorBuilder: (_, _) => const Gap(4), + separatorBuilder: (_, __) => const Gap(4), itemBuilder: (context, index) { final entry = entries[index]; return _FieldRow( @@ -607,7 +607,7 @@ class _RedisKeyEditorState extends material.State { ? material.Center(child: const Text('No items').muted()) : material.ListView.separated( itemCount: _listValue.length, - separatorBuilder: (_, _) => const Gap(4), + separatorBuilder: (_, __) => const Gap(4), itemBuilder: (context, i) => _IndexedValueRow( index: i, value: _listValue[i], @@ -656,7 +656,7 @@ class _RedisKeyEditorState extends material.State { ? material.Center(child: const Text('No members').muted()) : material.ListView.separated( itemCount: _setValue.length, - separatorBuilder: (_, _) => const Gap(4), + separatorBuilder: (_, __) => const Gap(4), itemBuilder: (context, index) => _MemberRow( member: _setValue[index], onDelete: () => _setRemove(_setValue[index]), @@ -715,7 +715,7 @@ class _RedisKeyEditorState extends material.State { ? material.Center(child: const Text('No members').muted()) : material.ListView.separated( itemCount: _zsetValue.length, - separatorBuilder: (_, _) => const Gap(4), + separatorBuilder: (_, __) => const Gap(4), itemBuilder: (context, index) { final (member, score) = _zsetValue[index]; return _ScoredMemberRow( diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 89ccc922..cd47133a 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -146,7 +146,7 @@ class _PreferencesAppearanceSectionState material.Widget build(material.BuildContext context) { return ListenableBuilder( listenable: _controller, - builder: (context, _) { + builder: (context, __) { final c = _controller; final themes = c.availableThemes; final refreshingThemes = c.isLoadingAvailableThemes; @@ -252,7 +252,7 @@ class _PreferencesAppearanceSectionState label: 'Motion', control: material.ListenableBuilder( listenable: QueryaMotionController.instance, - builder: (context, _) { + builder: (context, __) { final controller = QueryaMotionController.instance; return PreferencesDropdownMenu( value: controller.level, diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart index d776225b..a4c9b696 100644 --- a/lib/features/updater/update_available_badge.dart +++ b/lib/features/updater/update_available_badge.dart @@ -101,7 +101,7 @@ class UpdateAvailableBadgeState extends material.State material.Widget build(material.BuildContext context) { return ListenableBuilder( listenable: widget.controller, - builder: (context, _) { + builder: (context, __) { if (!widget.controller.showBadge) { return const material.SizedBox.shrink(); } diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index 38183fc8..4103b4d2 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -276,7 +276,7 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { alignment: material.Alignment.topCenter, children: [ ...previousChildren, - ?currentChild, + if (currentChild != null) currentChild, ], ); }, diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index 688d35bb..7af07be4 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -266,7 +266,7 @@ class _TabStripIndicator extends material.StatelessWidget { material.Widget build(material.BuildContext context) { return ListenableBuilder( listenable: Listenable.merge([left, width]), - builder: (context, _) { + builder: (context, __) { final w = width.value; if (w <= 0) return const material.SizedBox.shrink(); return material.Positioned( diff --git a/pubspec.yaml b/pubspec.yaml index 0e24245e..86e7e1f9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ version: 0.4.12+1 environment: - sdk: '>=3.12.2 <4.0.0' + sdk: '>=3.5.0 <4.0.0' dependencies: flutter: From 6f8f55d716a1c35b37eb249a753a7b4277e848f1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 29 Jul 2026 12:38:48 +0300 Subject: [PATCH 44/44] fix: revert scrollCacheExtent to cacheExtent for backwards compatibility and fix analysis_options syntax --- analysis_options.yaml | 13 ++++++++----- lib/features/mongodb/mongo_collections_view.dart | 3 +-- lib/features/mongodb/mongo_documents_view.dart | 3 +-- lib/features/postgresql/postgres_browser_views.dart | 11 +++++------ lib/features/redis/redis_keys_view.dart | 3 +-- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 280bae0c..a584332e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,13 +2,16 @@ include: package:flutter_lints/flutter.yaml analyzer: exclude: - - third_party/** - - build/** + - "third_party/**" + - "build/**" + errors: + unnecessary_underscores: ignore + prefer_initializing_formals: ignore + use_null_aware_elements: ignore + deprecated_member_use: ignore linter: rules: - prefer_const_constructors - prefer_const_declarations - unnecessary_underscores: false - prefer_initializing_formals: false - use_null_aware_elements: false + diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 33a0dbfc..8e6a9168 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -1,7 +1,6 @@ import 'dart:math' show min; import 'package:flutter/material.dart' as material; -import 'package:flutter/rendering.dart'; 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'; @@ -333,7 +332,7 @@ class _MongoCollectionsViewState extends material.State { child: const Text('No collections found').muted(), ) : material.ListView.separated( - scrollCacheExtent: const ScrollCacheExtent.pixels(400), + cacheExtent: 400, itemCount: _collections.length, separatorBuilder: (_, __) => Divider( height: 1, diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index e746e0f0..f75cc028 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:flutter/material.dart' as material; -import 'package:flutter/rendering.dart'; 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'; @@ -221,7 +220,7 @@ class _MongoDocumentsViewState extends material.State { ) : material.ListView.separated( padding: const material.EdgeInsets.all(16), - scrollCacheExtent: const ScrollCacheExtent.pixels(400), + cacheExtent: 400, itemCount: _documents.length, separatorBuilder: (_, __) => const Gap(8), itemBuilder: (context, i) { diff --git a/lib/features/postgresql/postgres_browser_views.dart b/lib/features/postgresql/postgres_browser_views.dart index e75b9cf2..4d67d575 100644 --- a/lib/features/postgresql/postgres_browser_views.dart +++ b/lib/features/postgresql/postgres_browser_views.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart' as material; -import 'package:flutter/rendering.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_metadata.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -130,7 +129,7 @@ class _PostgresIndexListViewState child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - scrollCacheExtent: const ScrollCacheExtent.pixels(400), + cacheExtent: 400, itemCount: _rows.isEmpty ? 1 : _rows.length, itemBuilder: (context, i) { if (_rows.isEmpty) { @@ -310,7 +309,7 @@ class _PostgresTriggerListViewState child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - scrollCacheExtent: const ScrollCacheExtent.pixels(400), + cacheExtent: 400, itemCount: _rows.isEmpty ? 1 : _rows.length, itemBuilder: (context, i) { if (_rows.isEmpty) { @@ -474,7 +473,7 @@ class _PostgresTypeListViewState extends material.State { child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - scrollCacheExtent: const ScrollCacheExtent.pixels(400), + cacheExtent: 400, itemCount: _rows.isEmpty ? 1 : _rows.length, itemBuilder: (context, i) { if (_rows.isEmpty) { @@ -626,7 +625,7 @@ class _PostgresExtensionListViewState child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - scrollCacheExtent: const ScrollCacheExtent.pixels(400), + cacheExtent: 400, itemCount: _rows.isEmpty ? 1 : _rows.length, itemBuilder: (context, i) { if (_rows.isEmpty) { @@ -782,7 +781,7 @@ class _PostgresFdwListViewState extends material.State { child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - scrollCacheExtent: const ScrollCacheExtent.pixels(400), + cacheExtent: 400, itemCount: totalItems, itemBuilder: (context, i) { if (i == 0) { diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 11444812..364402ce 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart' as material; -import 'package:flutter/rendering.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/theme/querya_semantic_palette.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -187,7 +186,7 @@ class _RedisKeysViewState extends material.State { ) : material.ListView.builder( padding: const material.EdgeInsets.all(16), - scrollCacheExtent: const ScrollCacheExtent.pixels(400), + cacheExtent: 400, itemCount: _keys.length + (_hasMore ? 1 : 0), itemBuilder: (context, i) { final shadcnCs = shadcn.Theme.of(context).colorScheme;