From 675e49843a406b74506f8846065fad801e585579 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 20:55:15 +0300 Subject: [PATCH 1/3] refactor(workspace): extract SqlWorkspaceToolbar and introduce BaseSqlExecutionDelegate Closes #1363 --- lib/features/mysql/mysql_sql_workspace.dart | 19 +- .../postgresql/postgres_sql_workspace.dart | 18 +- lib/features/sqlite/sqlite_sql_workspace.dart | 17 +- .../workspace/generic_sql_workspace.dart | 282 ++------------- .../workspace/sql_execution_delegate.dart | 27 ++ .../workspace/sql_workspace_toolbar.dart | 320 ++++++++++++++++++ .../workspace/sql_workspace_toolbar_test.dart | 131 +++++++ 7 files changed, 530 insertions(+), 284 deletions(-) create mode 100644 lib/features/workspace/sql_workspace_toolbar.dart create mode 100644 test/features/workspace/sql_workspace_toolbar_test.dart diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index b4c9f5c7..4ca7f8f2 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -14,7 +14,7 @@ import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/features/workspace/query_plan.dart'; /// Execution delegate for MySQL / MariaDB connections. -class MysqlSqlExecutionDelegate extends SqlExecutionDelegate { +class MysqlSqlExecutionDelegate extends BaseSqlExecutionDelegate { MysqlSqlExecutionDelegate({ required this.connectionRow, required this.isReadOnly, @@ -129,16 +129,13 @@ class MysqlSqlExecutionDelegate extends SqlExecutionDelegate { affected = _affectedInt(rs.affectedRows); } - String? statusMsg; - if (cols.isEmpty && outRows.isEmpty) { - statusMsg = affected != null - ? 'OK. Rows affected: $affected.' - : 'Command completed.'; - } else { - statusMsg = truncated - ? 'Showing first $cap row(s) (result capped).' - : '$n row(s).'; - } + final statusMsg = formatStatusMessage( + columnCount: cols.length, + rowCount: n, + affectedRows: affected, + isTruncated: truncated, + cap: cap, + ); return SqlExecutionResult( columns: cols, diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index a80cdb89..2d7b9375 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -21,7 +21,7 @@ String _pgSqlSessionDatabase(ConnectionRow row) { } /// Execution delegate for PostgreSQL connections. -class PostgresSqlExecutionDelegate extends SqlExecutionDelegate { +class PostgresSqlExecutionDelegate extends BaseSqlExecutionDelegate { PostgresSqlExecutionDelegate({ required this.connectionRow, required this.isReadOnly, @@ -139,14 +139,14 @@ class PostgresSqlExecutionDelegate extends SqlExecutionDelegate { ); final isTruncated = result.length >= cap; - String? statusMsg; - if (cols.isEmpty && outRows.isEmpty) { - statusMsg = 'Command completed. Rows affected: ${result.affectedRows}.'; - } else { - statusMsg = isTruncated - ? 'Showing first $cap row(s) (result capped).' - : '${result.length} row(s).'; - } + final statusMsg = formatStatusMessage( + columnCount: cols.length, + rowCount: outRows.length, + affectedRows: result.affectedRows, + isTruncated: isTruncated, + cap: cap, + successPrefix: 'Command completed.', + ); return SqlExecutionResult( columns: cols, diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 22ac1bf7..e1ddd296 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -14,7 +14,7 @@ import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/features/workspace/query_plan.dart'; /// Execution delegate for SQLite connections. -class SqliteSqlExecutionDelegate extends SqlExecutionDelegate { +class SqliteSqlExecutionDelegate extends BaseSqlExecutionDelegate { SqliteSqlExecutionDelegate({ required this.connectionRow, required this.isReadOnly, @@ -113,14 +113,13 @@ class SqliteSqlExecutionDelegate extends SqlExecutionDelegate { final outRows = await convertResultRowsToStringsAdaptive(rawRows); - String? statusMsg; - if (cols.isEmpty && outRows.isEmpty) { - statusMsg = 'Command completed.'; - } else if (truncated || (injectedLimit && results.length >= cap)) { - statusMsg = 'Showing first $cap row(s) (result capped).'; - } else { - statusMsg = '${results.length} row(s).'; - } + final isTruncated = truncated || (injectedLimit && results.length >= cap); + final statusMsg = formatStatusMessage( + columnCount: cols.length, + rowCount: results.length, + isTruncated: isTruncated, + cap: cap, + ); return SqlExecutionResult( columns: cols, diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 2f93c872..6da9a162 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -38,32 +38,11 @@ import 'package:querya_desktop/features/workspace/sql_query_tab_bar.dart'; import 'package:querya_desktop/features/workspace/sql_query_tab_session.dart'; import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; import 'package:querya_desktop/features/workspace/table_view_staging.dart'; -import 'package:querya_desktop/shared/widgets/querya_action_menu.dart'; +import 'package:querya_desktop/features/workspace/sql_workspace_toolbar.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Reusable base SQL workspace supporting multi-tab editing, statement execution, /// destructive query confirmations, unsaved changes guards, DML staging and result grids. -/// Entries of the Session menu in the SQL toolbar. -sealed class _SessionChoice { - const _SessionChoice(); -} - -class _ToggleAutocommit extends _SessionChoice { - const _ToggleAutocommit(); -} - -class _SetTimeout extends _SessionChoice { - const _SetTimeout(this.seconds); - final int? seconds; -} - -class _BeginTransaction extends _SessionChoice { - const _BeginTransaction(); -} - -class _OpenPreferences extends _SessionChoice { - const _OpenPreferences(); -} class GenericSqlWorkspace extends material.StatefulWidget { const GenericSqlWorkspace({ @@ -473,47 +452,6 @@ class GenericSqlWorkspaceState extends material.State { } } - /// Runs the selection, else the whole text. With [statementAtCursor] (run - /// statement) only the statement under the caret, see [SqlStatementSplitter]. - List> _sessionMenuItems() => [ - if (widget.supportsAutocommit) - QueryaActionMenuItem( - value: const _ToggleAutocommit(), - label: _autocommit ? 'Autocommit: on' : 'Autocommit: off', - ), - if (widget.supportsStmtTimeout) - for (final option in kSqlStatementTimeoutMenuItems) - QueryaActionMenuItem( - value: _SetTimeout(option.value), - label: 'Timeout: ${option.label}' - '${option.value == _queryTimeoutSeconds ? ' ✓' : ''}', - ), - if (widget.delegate.supportsTransactions && _txOpen != true) - const QueryaActionMenuItem( - value: _BeginTransaction(), - label: 'Begin transaction', - ), - const QueryaActionMenuItem( - value: _OpenPreferences(), - label: 'Preferences…', - ), - ]; - - void _onSessionChoice(_SessionChoice choice) { - switch (choice) { - case _ToggleAutocommit(): - invalidateAllPanes(); - setState(() => _autocommit = !_autocommit); - widget.onAutocommitChanged?.call(_autocommit); - case _SetTimeout(:final seconds): - _onStmtTimeoutChanged(seconds); - case _BeginTransaction(): - unawaited(runTxCommand('BEGIN')); - case _OpenPreferences(): - showPreferencesDialog(context); - } - } - final Map _gridSchemaCache = {}; /// Table schemas are cached per (database, schema, table), so any query on a @@ -1294,7 +1232,32 @@ class GenericSqlWorkspaceState extends material.State { material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: SqlEditorChrome.sqlToolbarDecoration(context), - child: _buildToolbarRow(context, session), + child: SqlWorkspaceToolbar( + session: session, + delegate: widget.delegate, + effectiveDatabase: effectiveDatabase, + autocommit: _autocommit, + supportsAutocommit: widget.supportsAutocommit, + supportsStmtTimeout: widget.supportsStmtTimeout, + txOpen: _txOpen, + queryTimeoutSeconds: _queryTimeoutSeconds, + headerBadge: widget.headerBadge, + extraTrailing: widget.extraToolbarTrailing?.call(context, session), + historyEnabled: widget.connectionRow.id != null && !session.running, + onExecute: (statementAtCursor) => unawaited(execute(session, statementAtCursor)), + onExplain: () => unawaited(explain(session)), + onCancel: () => unawaited(cancelRunning(session)), + onOpenHistory: () => _openHistory(session), + onOpenFile: () => unawaited(openSqlFile()), + onSaveFile: () => unawaited(saveSqlFile()), + onRunTxCommand: (cmd) => unawaited(runTxCommand(cmd)), + onToggleAutocommit: () { + invalidateAllPanes(); + setState(() => _autocommit = !_autocommit); + widget.onAutocommitChanged?.call(_autocommit); + }, + onTimeoutChanged: _onStmtTimeoutChanged, + ), ), const Divider(height: 1), Expanded( @@ -1352,195 +1315,4 @@ class GenericSqlWorkspaceState extends material.State { ), ); } - - /// The toolbar in one row: Run and Explain, the editor tools, then the - /// session state. On a narrow window it scrolls sideways instead of wrapping. - material.Widget _buildToolbarRow( - material.BuildContext context, - SqlQueryTabSession session, - ) { - final accent = context.workbench.accent; - final historyEnabled = widget.connectionRow.id != null && !session.running; - return material.LayoutBuilder( - builder: (context, constraints) { - // Below the breakpoint the labels hide and the icons stay (the - // tooltips name them), so the toolbar stays on one row. - final compact = constraints.maxWidth < _toolbarCompactWidth; - return material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.Row( - children: [ - if (widget.headerBadge != null) ...[ - widget.headerBadge!, - const material.SizedBox(width: 12), - ], - QueryaActionButton( - key: const material.ValueKey('run_script'), - label: 'Execute (F5)', - icon: material.Icons.play_arrow_rounded, - compact: compact, - loading: session.running, - onPressed: () => execute(session), - ), - const material.SizedBox(width: 4), - QueryaActionMenu( - key: const material.ValueKey('run_menu'), - items: [ - QueryaActionMenuItem( - value: true, - label: 'Run statement (${_modLabel}Enter)', - icon: material.Icons.short_text_rounded, - ), - const QueryaActionMenuItem( - value: false, - label: 'Run script (F5)', - icon: material.Icons.list_alt_rounded, - ), - ], - onSelected: (atCursor) { - if (!session.running) { - unawaited(execute(session, atCursor)); - } - }, - child: const material.Icon( - material.Icons.expand_more_rounded, - size: 16, - ), - ), - if (widget.delegate.supportsExplain) ...[ - const material.SizedBox(width: 8), - QueryaActionButton( - key: const material.ValueKey('explain_query'), - label: 'Explain', - icon: material.Icons.account_tree_outlined, - compact: compact, - tooltip: 'Show the query plan', - onPressed: session.running ? null : () => explain(session), - ), - ], - if (session.running && widget.delegate.supportsCancel) ...[ - const material.SizedBox(width: 8), - QueryaActionButton( - key: const material.ValueKey('cancel_query'), - label: 'Cancel', - icon: material.Icons.stop_rounded, - compact: compact, - isDestructive: true, - tooltip: 'Interrupt the running query', - onPressed: () => cancelRunning(session), - ), - ], - const material.SizedBox(width: 16), - QueryaIconButton( - key: const material.ValueKey('history_button'), - icon: const material.Icon(material.Icons.history_rounded), - tooltip: 'History (${_modLabel}H)', - onPressed: historyEnabled ? () => _openHistory(session) : null, - ), - QueryaIconButton( - key: const material.ValueKey('format_button'), - icon: const material.Icon(material.Icons.auto_fix_high_rounded), - tooltip: 'Format (Shift+Alt+F)', - onPressed: session.running ? null : () => session.formatSql(), - ), - QueryaIconButton( - key: const material.ValueKey('open_sql_button'), - icon: const material.Icon(material.Icons.folder_open_outlined), - tooltip: 'Open .sql file', - onPressed: () => unawaited(openSqlFile()), - ), - QueryaIconButton( - key: const material.ValueKey('save_sql_button'), - icon: const material.Icon(material.Icons.save_outlined), - tooltip: 'Save .sql file', - onPressed: () => unawaited(saveSqlFile()), - ), - const material.SizedBox(width: 16), - if (effectiveDatabase.isNotEmpty) ...[ - QueryaBadge.status( - effectiveDatabase, - status: QueryaBadgeStatus.neutral, - ), - const material.SizedBox(width: 8), - ], - if (widget.delegate.supportsTransactions) ...[ - _txBadge(), - if (_txOpen == true) ...[ - const material.SizedBox(width: 8), - QueryaActionButton( - label: 'Commit', - onPressed: - session.running ? null : () => runTxCommand('COMMIT'), - ), - const material.SizedBox(width: 4), - QueryaActionButton( - label: 'Rollback', - onPressed: - session.running ? null : () => runTxCommand('ROLLBACK'), - ), - ], - const material.SizedBox(width: 8), - ], - // Autocommit, statement timeout, Begin and Preferences live in one - // Session menu. - material.IgnorePointer( - ignoring: session.running, - child: material.Opacity( - opacity: session.running ? 0.5 : 1, - child: QueryaActionMenu<_SessionChoice>( - key: const material.ValueKey('session_menu'), - items: _sessionMenuItems(), - onSelected: (choice) => _onSessionChoice(choice), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon(material.Icons.tune_rounded, - size: 16, color: accent), - const material.SizedBox(width: 6), - if (!compact) ...[ - Text(_autocommit - ? 'Session · auto-commit' - : 'Session · manual'), - const material.SizedBox(width: 4), - ], - material.Icon(material.Icons.expand_more_rounded, - size: 16, color: accent), - ], - ), - ), - ), - ), - ), - if (widget.extraToolbarTrailing?.call(context, session) case final extra?) ...[ - const material.SizedBox(width: 8), - extra, - ], - ], - ), - ); - }, - ); - } - - /// Toolbar width below which button labels hide and only icons stay. - static const double _toolbarCompactWidth = 900; - - /// Transaction state as a badge: an open transaction is a warning, because - /// work can be lost, and looks different from no transaction. - material.Widget _txBadge() { - final open = _txOpen; - if (open == null) { - return const QueryaBadge.status('Transaction —', status: QueryaBadgeStatus.neutral); - } - if (open) { - return const QueryaBadge.status('Transaction open', status: QueryaBadgeStatus.warning); - } - return QueryaBadge.status( - _autocommit ? 'Auto-commit' : 'Manual commit', - status: _autocommit ? QueryaBadgeStatus.neutral : QueryaBadgeStatus.info, - ); - } } diff --git a/lib/features/workspace/sql_execution_delegate.dart b/lib/features/workspace/sql_execution_delegate.dart index 1566e6b5..7a45d165 100644 --- a/lib/features/workspace/sql_execution_delegate.dart +++ b/lib/features/workspace/sql_execution_delegate.dart @@ -65,3 +65,30 @@ abstract class SqlExecutionDelegate implements SqlQueryRunner { @override void dispose() {} } + +/// Base execution delegate providing common execution boilerplate such as +/// result status formatting and query limit injection. +abstract class BaseSqlExecutionDelegate extends SqlExecutionDelegate { + /// Formats the result status message consistently across database dialects. + String formatStatusMessage({ + required int columnCount, + required int rowCount, + int? affectedRows, + required bool isTruncated, + required int cap, + String? successPrefix, + }) { + if (columnCount == 0 && rowCount == 0) { + if (affectedRows != null) { + final prefix = successPrefix ?? 'OK.'; + return '$prefix Rows affected: $affectedRows.'; + } + return 'Command completed.'; + } + if (isTruncated) { + return 'Showing first $cap row(s) (result capped).'; + } + return '$rowCount row(s).'; + } +} + diff --git a/lib/features/workspace/sql_workspace_toolbar.dart b/lib/features/workspace/sql_workspace_toolbar.dart new file mode 100644 index 00000000..200ac6f9 --- /dev/null +++ b/lib/features/workspace/sql_workspace_toolbar.dart @@ -0,0 +1,320 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +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/workspace/sql_execution_delegate.dart'; +import 'package:querya_desktop/features/workspace/sql_query_tab_session.dart'; +import 'package:querya_desktop/shared/widgets/querya_action_menu.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Choices for the session options dropdown. +sealed class SqlWorkspaceSessionChoice { + const SqlWorkspaceSessionChoice(); +} + +class ToggleAutocommitChoice extends SqlWorkspaceSessionChoice { + const ToggleAutocommitChoice(); +} + +class SetStatementTimeoutChoice extends SqlWorkspaceSessionChoice { + const SetStatementTimeoutChoice(this.seconds); + final int? seconds; +} + +class BeginTransactionChoice extends SqlWorkspaceSessionChoice { + const BeginTransactionChoice(); +} + +class OpenPreferencesChoice extends SqlWorkspaceSessionChoice { + const OpenPreferencesChoice(); +} + +/// Standalone single-row toolbar for SQL workspaces. +/// +/// Encapsulates statement and script execution controls, explain/cancel actions, +/// history, format, file operations, active database/transaction indicators, +/// and session configuration menu. +class SqlWorkspaceToolbar extends StatelessWidget { + const SqlWorkspaceToolbar({ + super.key, + required this.session, + required this.delegate, + required this.effectiveDatabase, + required this.autocommit, + required this.supportsAutocommit, + required this.supportsStmtTimeout, + required this.txOpen, + required this.queryTimeoutSeconds, + this.headerBadge, + this.extraTrailing, + required this.historyEnabled, + required this.onExecute, + required this.onExplain, + required this.onCancel, + required this.onOpenHistory, + required this.onOpenFile, + required this.onSaveFile, + required this.onRunTxCommand, + required this.onToggleAutocommit, + required this.onTimeoutChanged, + }); + + final SqlQueryTabSession session; + final SqlExecutionDelegate delegate; + final String effectiveDatabase; + final bool autocommit; + final bool supportsAutocommit; + final bool supportsStmtTimeout; + final bool? txOpen; + final int? queryTimeoutSeconds; + final material.Widget? headerBadge; + final material.Widget? extraTrailing; + final bool historyEnabled; + + final void Function(bool statementAtCursor) onExecute; + final VoidCallback onExplain; + final VoidCallback onCancel; + final VoidCallback onOpenHistory; + final VoidCallback onOpenFile; + final VoidCallback onSaveFile; + final void Function(String command) onRunTxCommand; + final VoidCallback onToggleAutocommit; + final void Function(int? seconds) onTimeoutChanged; + + static const double compactWidthBreakpoint = 900; + static String get _modLabel => Platform.isMacOS ? '⌘' : 'Ctrl+'; + + List> _sessionMenuItems() => [ + if (supportsAutocommit) + QueryaActionMenuItem( + value: const ToggleAutocommitChoice(), + label: autocommit ? 'Autocommit: on' : 'Autocommit: off', + ), + if (supportsStmtTimeout) + for (final option in kSqlStatementTimeoutMenuItems) + QueryaActionMenuItem( + value: SetStatementTimeoutChoice(option.value), + label: 'Timeout: ${option.label}' + '${option.value == queryTimeoutSeconds ? ' ✓' : ''}', + ), + if (delegate.supportsTransactions && txOpen != true) + const QueryaActionMenuItem( + value: BeginTransactionChoice(), + label: 'Begin transaction', + ), + const QueryaActionMenuItem( + value: OpenPreferencesChoice(), + label: 'Preferences…', + ), + ]; + + void _onSessionChoice( + material.BuildContext context, + SqlWorkspaceSessionChoice choice, + ) { + switch (choice) { + case ToggleAutocommitChoice(): + onToggleAutocommit(); + case SetStatementTimeoutChoice(:final seconds): + onTimeoutChanged(seconds); + case BeginTransactionChoice(): + onRunTxCommand('BEGIN'); + case OpenPreferencesChoice(): + showPreferencesDialog(context); + } + } + + material.Widget _txBadge() { + final open = txOpen; + if (open == null) { + return const QueryaBadge.status( + 'Transaction —', + status: QueryaBadgeStatus.neutral, + ); + } + if (open) { + return const QueryaBadge.status( + 'Transaction open', + status: QueryaBadgeStatus.warning, + ); + } + return QueryaBadge.status( + autocommit ? 'Auto-commit' : 'Manual commit', + status: autocommit ? QueryaBadgeStatus.neutral : QueryaBadgeStatus.info, + ); + } + + @override + material.Widget build(material.BuildContext context) { + final accent = context.workbench.accent; + return material.LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < compactWidthBreakpoint; + return material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + children: [ + if (headerBadge != null) ...[ + headerBadge!, + const material.SizedBox(width: 12), + ], + QueryaActionButton( + key: const material.ValueKey('run_script'), + label: 'Execute (F5)', + icon: material.Icons.play_arrow_rounded, + compact: compact, + loading: session.running, + onPressed: () => onExecute(false), + ), + const material.SizedBox(width: 4), + QueryaActionMenu( + key: const material.ValueKey('run_menu'), + items: [ + QueryaActionMenuItem( + value: true, + label: 'Run statement (${_modLabel}Enter)', + icon: material.Icons.short_text_rounded, + ), + const QueryaActionMenuItem( + value: false, + label: 'Run script (F5)', + icon: material.Icons.list_alt_rounded, + ), + ], + onSelected: (atCursor) { + if (!session.running) { + onExecute(atCursor); + } + }, + child: const material.Icon( + material.Icons.expand_more_rounded, + size: 16, + ), + ), + if (delegate.supportsExplain) ...[ + const material.SizedBox(width: 8), + QueryaActionButton( + key: const material.ValueKey('explain_query'), + label: 'Explain', + icon: material.Icons.account_tree_outlined, + compact: compact, + tooltip: 'Show the query plan', + onPressed: session.running ? null : onExplain, + ), + ], + if (session.running && delegate.supportsCancel) ...[ + const material.SizedBox(width: 8), + QueryaActionButton( + key: const material.ValueKey('cancel_query'), + label: 'Cancel', + icon: material.Icons.stop_rounded, + compact: compact, + isDestructive: true, + tooltip: 'Interrupt the running query', + onPressed: onCancel, + ), + ], + const material.SizedBox(width: 16), + QueryaIconButton( + key: const material.ValueKey('history_button'), + icon: const material.Icon(material.Icons.history_rounded), + tooltip: 'History (${_modLabel}H)', + onPressed: historyEnabled ? onOpenHistory : null, + ), + QueryaIconButton( + key: const material.ValueKey('format_button'), + icon: const material.Icon(material.Icons.auto_fix_high_rounded), + tooltip: 'Format (Shift+Alt+F)', + onPressed: session.running ? null : () => session.formatSql(), + ), + QueryaIconButton( + key: const material.ValueKey('open_sql_button'), + icon: const material.Icon(material.Icons.folder_open_outlined), + tooltip: 'Open .sql file', + onPressed: onOpenFile, + ), + QueryaIconButton( + key: const material.ValueKey('save_sql_button'), + icon: const material.Icon(material.Icons.save_outlined), + tooltip: 'Save .sql file', + onPressed: onSaveFile, + ), + const material.SizedBox(width: 16), + if (effectiveDatabase.isNotEmpty) ...[ + QueryaBadge.status( + effectiveDatabase, + status: QueryaBadgeStatus.neutral, + ), + const material.SizedBox(width: 8), + ], + if (delegate.supportsTransactions) ...[ + _txBadge(), + if (txOpen == true) ...[ + const material.SizedBox(width: 8), + QueryaActionButton( + label: 'Commit', + onPressed: + session.running ? null : () => onRunTxCommand('COMMIT'), + ), + const material.SizedBox(width: 4), + QueryaActionButton( + label: 'Rollback', + onPressed: session.running + ? null + : () => onRunTxCommand('ROLLBACK'), + ), + ], + const material.SizedBox(width: 8), + ], + material.IgnorePointer( + ignoring: session.running, + child: material.Opacity( + opacity: session.running ? 0.5 : 1, + child: QueryaActionMenu( + key: const material.ValueKey('session_menu'), + items: _sessionMenuItems(), + onSelected: (choice) => _onSessionChoice(context, choice), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.tune_rounded, + size: 16, + color: accent, + ), + const material.SizedBox(width: 6), + if (!compact) ...[ + Text(autocommit + ? 'Session · auto-commit' + : 'Session · manual'), + const material.SizedBox(width: 4), + ], + material.Icon( + material.Icons.expand_more_rounded, + size: 16, + color: accent, + ), + ], + ), + ), + ), + ), + ), + if (extraTrailing != null) ...[ + const material.SizedBox(width: 8), + extraTrailing!, + ], + ], + ), + ); + }, + ); + } +} diff --git a/test/features/workspace/sql_workspace_toolbar_test.dart b/test/features/workspace/sql_workspace_toolbar_test.dart new file mode 100644 index 00000000..485ca146 --- /dev/null +++ b/test/features/workspace/sql_workspace_toolbar_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/features/workspace/query_plan.dart'; +import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; +import 'package:querya_desktop/features/workspace/sql_query_tab_session.dart'; +import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; +import 'package:querya_desktop/features/workspace/sql_workspace_toolbar.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class _FakeSqlExecutionDelegate extends BaseSqlExecutionDelegate { + @override + Future executeQuery( + String sql, { + int? limit, + Duration? timeout, + }) async => + const SqlExecutionResult(); + + @override + Future explainQuery(String sql) async => 'EXPLAIN PLAN'; + + @override + Future cancelQuery() async {} + + @override + bool get supportsTransactions => true; + + @override + bool get supportsExplain => true; + + @override + bool get supportsCancel => true; +} + +void main() { + Widget buildApp(Widget child) { + return ShadcnApp( + theme: ThemeData.dark(), + home: QueryaThemeScope( + theme: QueryaTheme.darkDefault, + child: material.Scaffold( + body: child, + ), + ), + ); + } + + group('BaseSqlExecutionDelegate', () { + test('formats status message for empty result with affected rows', () { + final delegate = _FakeSqlExecutionDelegate(); + final msg = delegate.formatStatusMessage( + columnCount: 0, + rowCount: 0, + affectedRows: 5, + isTruncated: false, + cap: 1000, + ); + expect(msg, 'OK. Rows affected: 5.'); + }); + + test('formats status message for truncated results', () { + final delegate = _FakeSqlExecutionDelegate(); + final msg = delegate.formatStatusMessage( + columnCount: 2, + rowCount: 100, + isTruncated: true, + cap: 100, + ); + expect(msg, 'Showing first 100 row(s) (result capped).'); + }); + + test('formats status message for normal results', () { + final delegate = _FakeSqlExecutionDelegate(); + final msg = delegate.formatStatusMessage( + columnCount: 2, + rowCount: 42, + isTruncated: false, + cap: 100, + ); + expect(msg, '42 row(s).'); + }); + }); + + group('SqlWorkspaceToolbar', () { + testWidgets('renders action buttons and handles execution callback', + (tester) async { + final session = SqlQueryTabSession( + id: '1', + title: 'Query 1', + initialSql: 'SELECT 1;', + ); + final delegate = _FakeSqlExecutionDelegate(); + bool executeCalled = false; + + await tester.pumpWidget( + buildApp( + SqlWorkspaceToolbar( + session: session, + delegate: delegate, + effectiveDatabase: 'test_db', + autocommit: true, + supportsAutocommit: true, + supportsStmtTimeout: true, + txOpen: false, + queryTimeoutSeconds: null, + historyEnabled: true, + onExecute: (statementAtCursor) => executeCalled = true, + onExplain: () {}, + onCancel: () {}, + onOpenHistory: () {}, + onOpenFile: () {}, + onSaveFile: () {}, + onRunTxCommand: (_) {}, + onToggleAutocommit: () {}, + onTimeoutChanged: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const material.ValueKey('run_script')), findsOneWidget); + expect(find.byKey(const material.ValueKey('explain_query')), findsOneWidget); + expect(find.text('test_db'), findsOneWidget); + + await tester.tap(find.byKey(const material.ValueKey('run_script'))); + expect(executeCalled, isTrue); + }); + }); +} From 12d11207c85070e79b2a114b276716198364aadc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 21:06:39 +0300 Subject: [PATCH 2/3] fix(workspace): fix toolbar test compile error and analyzer warnings - QueryaThemeScope takes data:, not theme:, in the toolbar test - drop imports and _modLabel left unused after extracting the toolbar - remove unnecessary theme scope import in the toolbar --- lib/features/workspace/generic_sql_workspace.dart | 5 ----- lib/features/workspace/sql_workspace_toolbar.dart | 1 - test/features/workspace/sql_workspace_toolbar_test.dart | 4 +--- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 6da9a162..283fd219 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -21,8 +21,6 @@ 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/ui/querya_shell_status.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/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/features/workspace/dml_preview_dialog.dart'; @@ -773,9 +771,6 @@ class GenericSqlWorkspaceState extends material.State { } } - /// Shortcut prefix shown in the run menu: ⌘ on macOS, Ctrl elsewhere. - static String get _modLabel => Platform.isMacOS ? '⌘' : 'Ctrl+'; - void _openHistory(SqlQueryTabSession session) { final cid = widget.connectionRow.id; if (cid == null || session.running) return; diff --git a/lib/features/workspace/sql_workspace_toolbar.dart b/lib/features/workspace/sql_workspace_toolbar.dart index 200ac6f9..c058303e 100644 --- a/lib/features/workspace/sql_workspace_toolbar.dart +++ b/lib/features/workspace/sql_workspace_toolbar.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart' as material; -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/workspace/sql_execution_delegate.dart'; diff --git a/test/features/workspace/sql_workspace_toolbar_test.dart b/test/features/workspace/sql_workspace_toolbar_test.dart index 485ca146..e5b8700b 100644 --- a/test/features/workspace/sql_workspace_toolbar_test.dart +++ b/test/features/workspace/sql_workspace_toolbar_test.dart @@ -2,10 +2,8 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; -import 'package:querya_desktop/features/workspace/query_plan.dart'; import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; import 'package:querya_desktop/features/workspace/sql_query_tab_session.dart'; -import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; import 'package:querya_desktop/features/workspace/sql_workspace_toolbar.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -39,7 +37,7 @@ void main() { return ShadcnApp( theme: ThemeData.dark(), home: QueryaThemeScope( - theme: QueryaTheme.darkDefault, + data: QueryaTheme.darkDefault, child: material.Scaffold( body: child, ), From a40bbcb51e238489fccebf5947ccc61d0241e7e9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 21:15:07 +0300 Subject: [PATCH 3/3] test(workspace): use const ThemeData.dark() in toolbar test --- test/features/workspace/sql_workspace_toolbar_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/features/workspace/sql_workspace_toolbar_test.dart b/test/features/workspace/sql_workspace_toolbar_test.dart index e5b8700b..6f5b7f5d 100644 --- a/test/features/workspace/sql_workspace_toolbar_test.dart +++ b/test/features/workspace/sql_workspace_toolbar_test.dart @@ -35,7 +35,7 @@ class _FakeSqlExecutionDelegate extends BaseSqlExecutionDelegate { void main() { Widget buildApp(Widget child) { return ShadcnApp( - theme: ThemeData.dark(), + theme: const ThemeData.dark(), home: QueryaThemeScope( data: QueryaTheme.darkDefault, child: material.Scaffold(