diff --git a/docs/ui-kit.md b/docs/ui-kit.md index b9093f0c..ae088ee9 100644 --- a/docs/ui-kit.md +++ b/docs/ui-kit.md @@ -53,6 +53,22 @@ QueryaIconButton( Anti-pattern: `IconButton` without a tooltip, hard-coded `Color(0xFF…)` icons. +### `QueryaActionButton` + +Labelled outline button for toolbars (SQL editor: Execute, Explain, Cancel, +Begin / Commit / Rollback). Optional `icon`, `loading` (spinner instead of the +icon, button disabled), `isDestructive`, `tooltip`; `onPressed: null` disables it. + +```dart +QueryaActionButton( + label: 'Explain', + icon: Icons.account_tree_outlined, + onPressed: session.running ? null : () => explain(session), +) +``` + +Anti-pattern: a hand-built `OutlineButton` with its own spinner / icon coloring. + ### `QueryaSpinner` Progress indicator with `QueryaSpinnerSize.sm | md | lg` and an optional diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index fee98198..7af42076 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -62,9 +62,15 @@ class ExtensionSqlExecutionDelegate extends SqlExecutionDelegate { Future explainQuery(String sql) async => throw UnsupportedError('EXPLAIN is not supported for generic extension drivers'); + @override + bool get supportsExplain => false; + @override Future cancelQuery() async {} + @override + bool get supportsCancel => false; + @override Future resolveTableSchema( String userSql, diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 941a7aba..2ecd6519 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -558,6 +558,73 @@ class GenericSqlWorkspaceState extends material.State { } } + /// Shows the query plan of the selection (or the whole editor text) in the + /// result grid, one plan line per row. + Future explain([SqlQueryTabSession? targetSession]) async { + final session = targetSession ?? _activeSession; + if (session.running || !widget.delegate.supportsExplain) return; + + final selection = session.controller.selection; + final sql = (selection.isValid && !selection.isCollapsed + ? selection.textInside(session.controller.text) + : session.controller.text) + .trim(); + if (sql.isEmpty) return; + + invalidatePane(session); + setState(() { + session.running = true; + session.error = null; + session.columns = []; + session.rows = []; + session.affectedRows = null; + session.statusLine = null; + session.resultGridPrimaryKeys = const []; + session.resultGridColumnDataTypes = null; + session.resultGridColumnMeta = null; + session.stagingBuffer?.dispose(); + session.stagingBuffer = null; + }); + QueryaShellStatus.instance.beginBusy(message: 'Explaining query…'); + try { + final plan = await widget.delegate.explainQuery(sql); + if (!mounted) return; + final lines = plan.split('\n').where((l) => l.trim().isNotEmpty).toList(); + invalidatePane(session); + setState(() { + session.columns = const ['QUERY PLAN']; + session.rows = [for (final l in lines) [l]]; + session.statusLine = 'Query plan: ${lines.length} line(s).'; + session.running = false; + }); + } catch (e) { + if (mounted) { + invalidatePane(session); + setState(() { + session.error = e.toString(); + session.running = false; + }); + } + } finally { + QueryaShellStatus.instance.endBusy(); + } + } + + /// Interrupts the statement that is running in [targetSession]. The pending + /// `execute` / `explain` then finishes with the driver's cancellation error. + Future cancelRunning([SqlQueryTabSession? targetSession]) async { + final session = targetSession ?? _activeSession; + if (!session.running || !widget.delegate.supportsCancel) return; + try { + await widget.delegate.cancelQuery(); + } catch (e) { + if (mounted) { + invalidatePane(session); + setState(() => session.error = 'Cancel failed: $e'); + } + } + } + Future applyStagedChanges([SqlQueryTabSession? targetSession]) async { final session = targetSession ?? _activeSession; if (widget.isReadOnly || @@ -883,7 +950,9 @@ class GenericSqlWorkspaceState extends material.State { Text('DB: $effectiveDatabase').muted().small(), if (widget.delegate.supportsTransactions) Text(_txLabel()).muted().small(), - OutlineButton( + QueryaActionButton( + label: 'History', + icon: material.Icons.history_rounded, size: ButtonSize.small, onPressed: widget.connectionRow.id != null && !session.running ? () { @@ -896,31 +965,34 @@ class GenericSqlWorkspaceState extends material.State { ); } : null, - leading: material.Icon( - material.Icons.history_rounded, - size: 16, - color: accent, - ), - child: const Text('History'), ), - OutlineButton( - onPressed: session.running ? null : () => execute(session), - leading: session.running - ? QueryaSpinner( - size: QueryaSpinnerSize.sm, - color: accent, - ) - : material.Icon( - material.Icons.play_arrow_rounded, - size: 18, - color: accent, - ), - child: const Text('Execute (F5)'), + QueryaActionButton( + label: 'Execute (F5)', + icon: material.Icons.play_arrow_rounded, + loading: session.running, + onPressed: () => execute(session), ), - OutlineButton( + if (widget.delegate.supportsExplain) + QueryaActionButton( + key: const material.ValueKey('explain_query'), + label: 'Explain', + icon: material.Icons.account_tree_outlined, + tooltip: 'Show the query plan', + onPressed: session.running ? null : () => explain(session), + ), + if (session.running && widget.delegate.supportsCancel) + QueryaActionButton( + key: const material.ValueKey('cancel_query'), + label: 'Cancel', + icon: material.Icons.stop_rounded, + isDestructive: true, + tooltip: 'Interrupt the running query', + onPressed: () => cancelRunning(session), + ), + QueryaActionButton( key: const material.ValueKey('open_diagram_tab'), + label: 'Diagram', onPressed: openDiagramTab, - child: const Text('Diagram'), ), if (widget.extraToolbarTrailing?.call(context, session) case final extra?) extra, @@ -976,17 +1048,17 @@ class GenericSqlWorkspaceState extends material.State { ), ], if (widget.delegate.supportsTransactions) ...[ - OutlineButton( + QueryaActionButton( + label: 'Begin', onPressed: session.running ? null : () => runTxCommand('BEGIN'), - child: const Text('Begin'), ), - OutlineButton( + QueryaActionButton( + label: 'Commit', onPressed: session.running ? null : () => runTxCommand('COMMIT'), - child: const Text('Commit'), ), - OutlineButton( + QueryaActionButton( + label: 'Rollback', onPressed: session.running ? null : () => runTxCommand('ROLLBACK'), - child: const Text('Rollback'), ), ], ], diff --git a/lib/features/workspace/sql_execution_delegate.dart b/lib/features/workspace/sql_execution_delegate.dart index 07142a58..f600a9cc 100644 --- a/lib/features/workspace/sql_execution_delegate.dart +++ b/lib/features/workspace/sql_execution_delegate.dart @@ -16,9 +16,16 @@ abstract class SqlExecutionDelegate { /// Explains the SQL query plan if supported by the DBMS. Future explainQuery(String sql); + /// Whether [explainQuery] works for this driver (shows the Explain button). + bool get supportsExplain => true; + /// Interrupts or cancels active query execution. Future cancelQuery(); + /// Whether [cancelQuery] really interrupts a running statement (shows the + /// Cancel button while a query runs). + bool get supportsCancel => true; + /// Whether the database supports explicit multi-statement transactions. bool get supportsTransactions; diff --git a/lib/shared/widgets/querya_action_button.dart b/lib/shared/widgets/querya_action_button.dart new file mode 100644 index 00000000..441db7d1 --- /dev/null +++ b/lib/shared/widgets/querya_action_button.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/ui/querya_tooltip.dart'; +import 'package:querya_desktop/shared/widgets/querya_spinner.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Labelled outline button for toolbars: optional leading icon, a [loading] +/// state with a spinner, and a destructive tone. +/// +/// While [loading] the button is disabled and shows a [QueryaSpinner] in place +/// of [icon]. Icon and spinner follow the workbench accent, or the destructive +/// color when [isDestructive] is set. +class QueryaActionButton extends material.StatelessWidget { + const QueryaActionButton({ + super.key, + required this.label, + this.icon, + this.onPressed, + this.loading = false, + this.isDestructive = false, + this.tooltip, + this.size = ButtonSize.normal, + }); + + final String label; + final material.IconData? icon; + + /// `null` disables the button. + final material.VoidCallback? onPressed; + + /// Shows a spinner instead of [icon] and disables the button. + final bool loading; + + /// Uses the destructive color for the icon and the label. + final bool isDestructive; + + /// Shown on hover after [kQueryaTooltipWait]. + final String? tooltip; + final ButtonSize size; + + @override + material.Widget build(material.BuildContext context) { + final workbench = context.workbench; + final tone = isDestructive ? workbench.destructive : workbench.accent; + final enabled = onPressed != null && !loading; + + material.Widget? leading; + if (loading) { + leading = QueryaSpinner(size: QueryaSpinnerSize.sm, color: tone); + } else if (icon != null) { + leading = material.Icon(icon, size: 18, color: tone); + } + + material.Widget button = OutlineButton( + size: size, + onPressed: enabled ? onPressed : null, + leading: leading, + child: isDestructive + ? Text(label, style: material.TextStyle(color: tone)) + : Text(label), + ); + + if (tooltip != null && tooltip!.isNotEmpty) { + button = material.Tooltip( + message: tooltip!, + waitDuration: kQueryaTooltipWait, + child: button, + ); + } + return button; + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index e5824624..eeef993d 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -20,6 +20,7 @@ export 'querya_dropdown.dart' QueryaDropdownTokens, kPreferencesLabelWidth; export 'connection_tree_loading_row.dart'; +export 'querya_action_button.dart'; export 'querya_badge.dart'; export 'querya_empty_state.dart'; export 'querya_icon_button.dart'; diff --git a/test/e2e/02_sql_workspaces/e2e_explain_and_cancel_query_test.dart b/test/e2e/02_sql_workspaces/e2e_explain_and_cancel_query_test.dart new file mode 100644 index 00000000..98e6d4a3 --- /dev/null +++ b/test/e2e/02_sql_workspaces/e2e_explain_and_cancel_query_test.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fake_sql_execution_delegate.dart'; +import '../helpers/e2e_app_harness.dart'; +import '../helpers/e2e_sql_workspace_helper.dart'; + +const _timeout = Timeout(Duration(seconds: 60)); + +void main() { + final app = E2eAppHarness(prefix: 'querya_e2e_explain_'); + late E2eSqlWorkspace ws; + setUpAll(() async { + await app.setUpAll(); + ws = await E2eSqlWorkspace.create(); + }); + tearDownAll(app.tearDownAll); + + testWidgets('Explain shows the plan line by line without running the query', + timeout: _timeout, (tester) async { + final delegate = FakeSqlExecutionDelegate() + ..explainPlan = 'Seq Scan on users\n Filter: (id > 1)\n'; + await ws.pump(tester, delegate, initialSql: 'SELECT * FROM users'); + + await tester.tap(find.text('Explain')); + await E2eSqlWorkspace.settle(tester); + + expect(delegate.explained, ['SELECT * FROM users']); + expect(delegate.executed, isEmpty); + expect(find.text('QUERY PLAN'), findsWidgets); + expect(find.text('Seq Scan on users'), findsOneWidget); + expect(find.textContaining('Filter: (id > 1)'), findsOneWidget); + expect(find.text('Query plan: 2 line(s).'), findsOneWidget); + }); + + testWidgets('Explain with an empty editor does nothing', + timeout: _timeout, (tester) async { + final delegate = FakeSqlExecutionDelegate(); + await ws.pump(tester, delegate); + + await tester.tap(find.text('Explain')); + await E2eSqlWorkspace.settle(tester); + + expect(delegate.explained, isEmpty); + }); + + testWidgets('a failing Explain reports the error', + timeout: _timeout, (tester) async { + final delegate = FakeSqlExecutionDelegate() + ..explainError = StateError('syntax error at or near "SELEC"'); + await ws.pump(tester, delegate, initialSql: 'SELEC 1'); + + await tester.tap(find.text('Explain')); + await E2eSqlWorkspace.settle(tester); + + expect(find.textContaining('syntax error at or near'), findsWidgets); + expect(find.text('Cancel'), findsNothing); + }); + + testWidgets('Cancel appears while a query runs and interrupts it', + timeout: _timeout, (tester) async { + final delegate = FakeSqlExecutionDelegate() + ..gate = Completer() + ..cancelAbortsGate = true; + await ws.pump(tester, delegate, initialSql: 'SELECT pg_sleep(60)'); + expect(find.text('Cancel'), findsNothing); + + await E2eSqlWorkspace.ctrl(tester, LogicalKeyboardKey.enter); + await E2eSqlWorkspace.settle(tester); + expect(delegate.executed, ['SELECT pg_sleep(60)']); + expect(find.text('Cancel'), findsOneWidget); + expect(delegate.cancelCount, 0); + + await tester.tap(find.text('Cancel')); + await E2eSqlWorkspace.settle(tester); + + expect(delegate.cancelCount, 1); + expect(find.textContaining('canceling statement'), findsWidgets); + expect(find.text('Cancel'), findsNothing); + // The editor is usable again. + delegate.gate = null; + await E2eSqlWorkspace.ctrl(tester, LogicalKeyboardKey.enter); + await E2eSqlWorkspace.settle(tester); + expect(delegate.executed, hasLength(2)); + }); + + testWidgets('a driver without Explain or Cancel hides both buttons', + timeout: _timeout, (tester) async { + final delegate = FakeSqlExecutionDelegate() + ..explainSupported = false + ..cancelSupported = false + ..gate = Completer(); + await ws.pump(tester, delegate, initialSql: 'SELECT 1'); + expect(find.text('Explain'), findsNothing); + + await E2eSqlWorkspace.ctrl(tester, LogicalKeyboardKey.enter); + await E2eSqlWorkspace.settle(tester); + expect(find.text('Cancel'), findsNothing); + + delegate.gate!.complete(); + await E2eSqlWorkspace.settle(tester); + await tester.pumpWidget(const material.SizedBox()); + }); +} diff --git a/test/shared/widgets/querya_action_button_test.dart b/test/shared/widgets/querya_action_button_test.dart new file mode 100644 index 00000000..c720373f --- /dev/null +++ b/test/shared/widgets/querya_action_button_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('QueryaActionButton', () { + testWidgets('shows label and icon and reports taps', (tester) async { + var taps = 0; + await tester.pumpWidget(queryaThemeTestShell( + child: QueryaActionButton( + label: 'Explain', + icon: material.Icons.account_tree_outlined, + onPressed: () => taps++, + ), + )); + + expect(find.text('Explain'), findsOneWidget); + expect(find.byIcon(material.Icons.account_tree_outlined), findsOneWidget); + await tester.tap(find.text('Explain')); + expect(taps, 1); + }); + + testWidgets('onPressed null disables the button', (tester) async { + await tester.pumpWidget(queryaThemeTestShell( + child: const QueryaActionButton(label: 'Begin'), + )); + + final button = tester.widget(find.byType(OutlineButton)); + expect(button.onPressed, isNull); + }); + + testWidgets('loading swaps the icon for a spinner and disables taps', + (tester) async { + var taps = 0; + await tester.pumpWidget(queryaThemeTestShell( + child: QueryaActionButton( + label: 'Execute', + icon: material.Icons.play_arrow_rounded, + loading: true, + onPressed: () => taps++, + ), + )); + + expect(find.byType(QueryaSpinner), findsOneWidget); + expect(find.byIcon(material.Icons.play_arrow_rounded), findsNothing); + await tester.tap(find.text('Execute'), warnIfMissed: false); + expect(taps, 0); + }); + + testWidgets('destructive tone colors the label', (tester) async { + await tester.pumpWidget(queryaThemeTestShell( + child: QueryaActionButton( + label: 'Cancel', + icon: material.Icons.stop_rounded, + isDestructive: true, + onPressed: () {}, + ), + )); + + final text = tester.widget(find.text('Cancel')); + expect(text.style?.color, isNotNull); + }); + + testWidgets('tooltip wraps the button', (tester) async { + await tester.pumpWidget(queryaThemeTestShell( + child: QueryaActionButton( + label: 'Explain', + tooltip: 'Show the query plan', + onPressed: () {}, + ), + )); + + final tip = tester.widget(find.byType(material.Tooltip)); + expect(tip.message, 'Show the query plan'); + }); + }); +} diff --git a/test/support/fake_sql_execution_delegate.dart b/test/support/fake_sql_execution_delegate.dart index d05e51f6..6fc62f68 100644 --- a/test/support/fake_sql_execution_delegate.dart +++ b/test/support/fake_sql_execution_delegate.dart @@ -15,7 +15,18 @@ class FakeSqlExecutionDelegate extends SqlExecutionDelegate { /// "running"). Completer? gate; + /// Plan returned by `explainQuery`; throws it when it is an [Exception]. + String explainPlan = 'plan'; + Object? explainError; + + /// When true, `cancelQuery` aborts a query held by [gate] like a driver would. + bool cancelAbortsGate = false; + + bool explainSupported = true; + bool cancelSupported = true; + final executed = []; + final explained = []; final appliedPlans = []; var cancelCount = 0; var disposeCount = 0; @@ -40,10 +51,27 @@ class FakeSqlExecutionDelegate extends SqlExecutionDelegate { } @override - Future explainQuery(String sql) async => 'plan'; + Future explainQuery(String sql) async { + explained.add(sql); + final error = explainError; + if (error != null) throw error; + return explainPlan; + } + + @override + bool get supportsExplain => explainSupported; + + @override + Future cancelQuery() async { + cancelCount++; + final pending = gate; + if (cancelAbortsGate && pending != null && !pending.isCompleted) { + pending.completeError(StateError('canceling statement due to user request')); + } + } @override - Future cancelQuery() async => cancelCount++; + bool get supportsCancel => cancelSupported; @override bool get supportsTransactions => false;