Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/ui-kit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/features/extensions/extension_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,15 @@ class ExtensionSqlExecutionDelegate extends SqlExecutionDelegate {
Future<String> explainQuery(String sql) async =>
throw UnsupportedError('EXPLAIN is not supported for generic extension drivers');

@override
bool get supportsExplain => false;

@override
Future<void> cancelQuery() async {}

@override
bool get supportsCancel => false;

@override
Future<SqlResultGridSchema> resolveTableSchema(
String userSql,
Expand Down
128 changes: 100 additions & 28 deletions lib/features/workspace/generic_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,73 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
}
}

/// Shows the query plan of the selection (or the whole editor text) in the
/// result grid, one plan line per row.
Future<void> 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<void> 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<void> applyStagedChanges([SqlQueryTabSession? targetSession]) async {
final session = targetSession ?? _activeSession;
if (widget.isReadOnly ||
Expand Down Expand Up @@ -883,7 +950,9 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
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
? () {
Expand All @@ -896,31 +965,34 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
);
}
: 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,
Expand Down Expand Up @@ -976,17 +1048,17 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
),
],
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'),
),
],
],
Expand Down
7 changes: 7 additions & 0 deletions lib/features/workspace/sql_execution_delegate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@ abstract class SqlExecutionDelegate {
/// Explains the SQL query plan if supported by the DBMS.
Future<String> explainQuery(String sql);

/// Whether [explainQuery] works for this driver (shows the Explain button).
bool get supportsExplain => true;

/// Interrupts or cancels active query execution.
Future<void> 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;

Expand Down
72 changes: 72 additions & 0 deletions lib/shared/widgets/querya_action_button.dart
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions lib/shared/widgets/widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading