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 64f6886..f611ff2 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 3cc05eb..b8e380c 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 8f1f267..fbe77a8 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 70db9a3..69d06c0 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 0000000..6a9e48d --- /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 0000000..f1d4daf --- /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 d7f6d10..4100bc6 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 6d343cf..19036f2 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 cc1c5d4..a34e064 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 3d801c6..98fe6e5 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 51ead1e..a24d20d 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 5021b92..5bccba4 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 cc6d687..c23254e 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 d054ee5..8a540a9 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 b403fb6..2efdee5 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 c487676..25995f4 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 d0ee2a0..4555d85 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 0000000..7bc6afd --- /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 3fcb27e..4c9f5bb 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 0000000..bdeb3b5 --- /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 0000000..3aa281f --- /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 4100bc6..958721a 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 c23254e..2e01abf 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 69d06c0..7ab1795 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 f1d4daf..297dcf4 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 19036f2..2874342 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 98fe6e5..eaf0e58 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 a24d20d..4281af7 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 2efdee5..1456068 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 3181f68..dd43253 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 bdeb3b5..7f93df8 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 b3c4005..bf9f3e9 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 38e3a40..3811132 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 7ab1795..fc84518 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 dd43253..f37aea2 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 f37aea2..20e00f9 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 9fa2d07..cb77c4d 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 96dd97c..6210887 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 d7064e3..14efdd3 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 fc84518..4324062 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 2874342..0507777 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 a34e064..a1193b3 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 eaf0e58..069001b 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 4281af7..ad5f838 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 5bccba4..ec01f98 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 2e01abf..472ae2d 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 8a540a9..f8ba2a1 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 1456068..767fcb7 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 362a394..c39090f 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 a7d91d0..aa26700 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 627d55d..a64b09b 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 cb77c4d..0204610 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 c7d1908..d954b6d 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 278d35f..f53b85e 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 77a54f9..a16a72b 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 1a92cea..ec8a2a3 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 112d03f..0c67cc9 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 ecab84c..d39b686 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 0204610..dfd2400 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 40c3b1c..8db6875 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 25995f4..dc26a23 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 72384c1..832de9b 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 e0afb04..d2a86c5 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 5e859b0..6f61d05 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 2fce9ac..7349b78 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 bc68c22..4905121 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 d34f369..39632c9 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 2418fe6..a897eaa 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 3a3924a..7302877 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 20e00f9..9bf1f38 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 27f4d17..dce5756 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 0000000..412f90c --- /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 e634272..be40172 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 947072b..d91709b 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 0cbb61f..db2d299 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 6253709..3ef0399 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 bf9f3e9..c33bd71 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 3811132..79f795c 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 7349b78..d87455d 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 4905121..4fc3efa 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 6a9e48d..58c66d8 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 0507777..8890294 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 a1193b3..db1f4f8 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 069001b..41f5b5d 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 ec01f98..f592e3c 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 472ae2d..cdb0aa4 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 f8ba2a1..9822994 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 767fcb7..50db826 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 7f93df8..20025fa 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 4324062..5cb31f8 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 58c66d8..90746d0 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 20025fa..c9847d2 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 0000000..ad8ef9c --- /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 ddee189..a1f569f 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 e8c4f82..ae4d82a 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 0c0fb26..040322c 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 2d9b062..50a01a8 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 01f085e..f0070a5 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 fdcfdd8..ae55573 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 0000000..83b18ec --- /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 db1f4f8..68e991b 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 a8b9319..01b8cea 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 6defda1..fe40911 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 f611ff2..7625db0 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 fbe77a8..afe0a7e 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 4527cf8..9459d9c 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 8db6875..be95a5b 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 654ceda..18735fc 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 5cb31f8..eccce45 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 297dcf4..eaba0b4 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 0000000..264c8cf --- /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 0000000..e9daae2 --- /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 958721a..4c3cef4 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 41f5b5d..1af080a 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 ad5f838..f25bb71 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 50db826..861e0cd 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 2ce785d..9fc2bec 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 4555d85..d254bd5 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 eebfb7f..c9582d9 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 61b0ff6..28b8b9d 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 e21be0e..820b992 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 99a90e5..af169d7 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 eccce45..8dce267 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 e9daae2..20ef57c 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 1af080a..37a0edb 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 f25bb71..f0afecd 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 861e0cd..0563f51 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 28b8b9d..36e0b2e 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 39632c9..d218b95 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 ed6e14e..21a3cfe 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 8dce267..fafc245 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 d530654..843cdef 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 4c3cef4..1730bc1 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 8890294..036a9dc 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 68e991b..d745ccf 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 37a0edb..a6f08dc 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 f0afecd..1579556 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 f592e3c..c6ecc33 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 cdb0aa4..fb0f650 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 9822994..8d981b0 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 0563f51..1b5dcb0 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 38a3446..a32fd6d 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 dc26a23..b64f247 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 6c73fe4..b4c6767 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 f4c6a85..37c3753 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 d87455d..a2f0a27 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 11eef0a..8735e21 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 af10e3d..2bb39f8 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 e1c09ef..d18b62b 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 1f28fef..49177e7 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 c523460..fc3a2f1 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 7302877..6d2554c 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 c9582d9..6600fb9 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 0000000..4a89571 --- /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 7bc6afd..aabaee4 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 4c9f5bb..49ae954 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 3aa281f..4e79bf5 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 4a89571..29f71fd 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 bd7ce79..981538b 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 31e15ca..a2d7479 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 82ca372..6ad7ed6 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 1730bc1..e801fef 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 036a9dc..e93bfa3 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 d745ccf..2ee9b6a 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 a6f08dc..abe2b92 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 c6ecc33..5a50066 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 fb0f650..6e6f509 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 8d981b0..4540fe6 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 1b5dcb0..6be778e 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 3ed40d4..08ee32c 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 496dbc0..1ca2f71 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 3d30433..bcb9823 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 b1586c1..8ca301f 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 2bd146c..7c1d6af 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 a373fb1..98bec1c 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 d864cd8..8c2fbe1 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 51032fd..dacae45 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 0000000..2560c2b --- /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 adda7db..a3461d8 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 d218b95..0eeb1d8 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 a2d7479..6e2b647 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 0000000..700f473 --- /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 e93bfa3..f70afaa 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 08ee32c..587dfad 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 9bf1f38..1b85f76 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 587dfad..13845d8 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 1ca2f71..5185f21 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 8ca301f..88bf852 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 7c1d6af..de32365 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 98bec1c..b66d9e2 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 8c2fbe1..f006c45 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 16f9f25..f77ef8b 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 0cd85be..f511f59 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 636aa0c..5a85e20 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 5a47861..0ecab18 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 de451b1..44d4253 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 9fc2bec..f56c377 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 e3bf96c..2ed4a56 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 981538b..337a412 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 e034227..f7a2d6f 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 130f56f..4e0e544 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 9e528bb..4b0a61b 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 4a8c096..16a25e1 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 d603850..cbf08f2 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 f7788a5..5b0cb1e 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 cce26a9..a19f14d 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 4ffa5d0..db9096d 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 e47b127..4aef0e9 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 0eeb1d8..5da85b5 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 37c3753..513e7d2 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 f7788a5..ed07fa7 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 2bb39f8..567f9af 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 d281bb0..1c6c73a 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 48cd198..08c3d73 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 48665b4..af037da 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 4ffa5d0..d78a561 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 1c0d93b..89ccc92 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 a897eaa..61817ed 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 01b8cea..30cdd59 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 832de9b..d776225 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 d91709b..688d35b 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 2da177d..ddafa90 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 4540fe6..7323f44 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 6aa3277..2f1b953 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 a32fd6d..a52e780 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 13845d8..bdeb803 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 b64f247..0671338 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 b4c6767..1d359f1 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 513e7d2..1fe5166 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 5185f21..c0f9681 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 567f9af..8973c2c 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 49ae954..10890c1 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 312db97..2be7c71 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 8735e21..0405439 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 f24f201..f3f5a2f 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 d254bd5..0625238 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 7ed1903..8e6a916 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 4c88374..20e7686 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 bba3ba4..6cc9daf 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 a7319e8..f75cc02 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 5ece7c4..5bf34a2 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 f511f59..2efe818 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 5a46f64..5a0d141 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 071827d..364402c 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 0ecab18..f8dd7eb 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 6600fb9..4103b4d 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 7275029..274dcd7 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 e49694f..0321021 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 a22fed2..d262040 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 f70afaa..8f06118 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 2ee9b6a..4db6c55 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 abe2b92..d8673c1 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 5a50066..8708434 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 6e6f509..0ea0f9c 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 7323f44..5b74129 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 6be778e..d783df0 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 2f1b953..8621793 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 de32365..47f573f 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 1c6c73a..01d4d0a 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 08c3d73..ab73ce5 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 b66d9e2..4442e66 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 b526cc2..280bae0 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 a52e780..f4b45fe 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 a2f0a27..67b99ab 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 123cdf7..79e143c 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 8e6a916..3b8b282 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 f75cc02..d022f29 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 4d67d57..e75b9cf 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 5a0d141..becf567 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 364402c..1144481 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 4103b4d..38183fc 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 ff1df34..4d44b1b 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 2bfd7ca..0e24245 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 3fec8d0..51f10be 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 18735fc..5f10c27 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 f4b45fe..a52e780 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 1fe5166..f7cc9ce 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 67b99ab..a2f0a27 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 79e143c..123cdf7 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 3b8b282..33a0dbf 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 8973c2c..7c9221c 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 d022f29..e746e0f 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 becf567..5a0d141 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 89ccc92..cd47133 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 d776225..a4c9b69 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 38183fc..4103b4d 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 688d35b..7af07be 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 0e24245..86e7e1f 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 280bae0..a584332 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 33a0dbf..8e6a916 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 e746e0f..f75cc02 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 e75b9cf..4d67d57 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 1144481..364402c 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;