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
29 changes: 24 additions & 5 deletions lib/features/workspace/generic_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
session.rows = [];
session.affectedRows = null;
session.statusLine = null;
session.elapsed = null;
session.planRoot = null;
session.resultGridPrimaryKeys = const [];
session.resultGridColumnDataTypes = null;
Expand Down Expand Up @@ -756,6 +757,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
session.stagingBuffer = null;
session.statusLine = scriptStatus ??
_resultStatus(result, cols, outRows, null);
session.elapsed = multi ? sw.elapsed : result.elapsed ?? sw.elapsed;
session.running = false;
});
// Anything that is not a read may change table shapes: forget the cache.
Expand Down Expand Up @@ -903,6 +905,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
session.rows = [];
session.affectedRows = null;
session.statusLine = null;
session.elapsed = null;
session.planRoot = null;
session.resultGridPrimaryKeys = const [];
session.resultGridColumnDataTypes = null;
Expand Down Expand Up @@ -1322,6 +1325,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
isLoading: session.running,
affectedRows: session.affectedRows,
statusLine: session.statusLine,
elapsed: session.elapsed,
stagingBuffer: session.stagingBuffer,
columnDataTypes: session.resultGridColumnDataTypes,
onApplyChanges: widget.isReadOnly ||
Expand Down Expand Up @@ -1349,7 +1353,12 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
) {
final accent = context.workbench.accent;
final historyEnabled = widget.connectionRow.id != null && !session.running;
return material.SingleChildScrollView(
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: [
Expand All @@ -1361,6 +1370,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
key: const material.ValueKey('run_script'),
label: 'Execute (F5)',
icon: material.Icons.play_arrow_rounded,
compact: compact,
loading: session.running,
onPressed: () => execute(session),
),
Expand Down Expand Up @@ -1395,6 +1405,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
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),
),
Expand All @@ -1405,6 +1416,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
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),
Expand Down Expand Up @@ -1480,10 +1492,12 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
material.Icon(material.Icons.tune_rounded,
size: 16, color: accent),
const material.SizedBox(width: 6),
Text(_autocommit
? 'Session · auto-commit'
: 'Session · manual'),
const material.SizedBox(width: 4),
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),
],
Expand All @@ -1499,8 +1513,13 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
],
),
);
},
);
}

/// 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() {
Expand Down
21 changes: 21 additions & 0 deletions lib/features/workspace/results_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class ResultsTab extends material.StatefulWidget {
this.isLoading = false,
this.affectedRows,
this.statusLine,
this.elapsed,
this.showExportToolbar = true,
this.stagingBuffer,
this.onApplyChanges,
Expand All @@ -65,6 +66,9 @@ class ResultsTab extends material.StatefulWidget {
final bool isLoading;
final int? affectedRows;
final String? statusLine;

/// Time the query took, shown as a badge next to the row count.
final Duration? elapsed;
final bool showExportToolbar;
final DataGridStagingBuffer? stagingBuffer;
final material.VoidCallback? onApplyChanges;
Expand Down Expand Up @@ -417,6 +421,14 @@ class _ResultsTabState extends material.State<ResultsTab> {
? 'Rows affected: ${widget.affectedRows}'
: '${filteredRows.length}${_filterText.isNotEmpty ? ' of ${effectiveRows.length}' : ''} rows'),
).small().semiBold(),
if (widget.elapsed != null) ...[
const Gap(8),
QueryaBadge(
key: const material.ValueKey('result_elapsed'),
label: formatResultElapsed(widget.elapsed!),
isMonospace: true,
),
],

const Gap(16),

Expand Down Expand Up @@ -664,3 +676,12 @@ Future<void> _showSaveFileErrorDialog(material.BuildContext context) {
),
);
}

/// `14 ms`, `1.2 s`, `2 min 5 s`: what the elapsed badge shows.
String formatResultElapsed(Duration d) {
if (d.inMilliseconds < 1000) return '${d.inMilliseconds} ms';
if (d.inSeconds < 60) {
return '${(d.inMilliseconds / 1000).toStringAsFixed(1)} s';
}
return '${d.inMinutes} min ${d.inSeconds % 60} s';
}
3 changes: 3 additions & 0 deletions lib/features/workspace/sql_query_tab_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ class SqlQueryTabSession {
int? affectedRows;
String? statusLine;

/// How long the last run took (the whole script for a multi-statement run).
Duration? elapsed;

/// The EXPLAIN plan as a tree, and whether the result area shows it (or text).
PlanNode? planRoot;
bool planAsTree = true;
Expand Down
21 changes: 16 additions & 5 deletions lib/shared/widgets/querya_action_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class QueryaActionButton extends material.StatelessWidget {
this.isDestructive = false,
this.tooltip,
this.size = ButtonSize.normal,
this.compact = false,
});

final String label;
Expand All @@ -38,6 +39,10 @@ class QueryaActionButton extends material.StatelessWidget {
final String? tooltip;
final ButtonSize size;

/// Hides the label and keeps the icon (narrow toolbars). The label becomes
/// the tooltip when none is set. Without an [icon], [compact] has no effect.
final bool compact;

@override
material.Widget build(material.BuildContext context) {
final workbench = context.workbench;
Expand All @@ -51,18 +56,24 @@ class QueryaActionButton extends material.StatelessWidget {
leading = material.Icon(icon, size: 18, color: tone);
}

final iconOnly = compact && leading != null;
material.Widget button = OutlineButton(
size: size,
onPressed: enabled ? onPressed : null,
leading: leading,
child: isDestructive
? Text(label, style: material.TextStyle(color: tone))
: Text(label),
child: iconOnly
? const material.SizedBox.shrink()
: isDestructive
? Text(label, style: material.TextStyle(color: tone))
: Text(label),
);

if (tooltip != null && tooltip!.isNotEmpty) {
final hint = (tooltip != null && tooltip!.isNotEmpty)
? tooltip!
: (iconOnly ? label : null);
if (hint != null) {
button = material.Tooltip(
message: tooltip!,
message: hint,
waitDuration: kQueryaTooltipWait,
child: button,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));

expect(find.textContaining('Execute'), findsWidgets);
// The label hides on a narrow window; the button keeps its key.
expect(find.byKey(const material.ValueKey('run_script')), findsOneWidget);
});
});

Expand Down
36 changes: 36 additions & 0 deletions test/features/workspace/generic_sql_workspace_shortcuts_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart';
import 'package:querya_desktop/core/storage/local_db.dart';
import 'package:querya_desktop/features/settings/preferences_shortcuts_section.dart';
import 'package:querya_desktop/features/workspace/generic_sql_workspace.dart';
import 'package:querya_desktop/features/workspace/results_tab.dart' show formatResultElapsed;
import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' show OutlineButton;

Expand Down Expand Up @@ -366,6 +367,41 @@ void main() {
expect(tester.getCenter(session).dy, closeTo(tester.getCenter(run).dy, 1));
});

testWidgets('below the breakpoint the labels hide and the icons stay',
timeout: _timeout, (tester) async {
await pumpWorkspace(tester, FakeSqlExecutionDelegate());
expect(find.text('Execute (F5)'), findsOneWidget);
expect(find.text('Explain'), findsOneWidget);

await tester.binding.setSurfaceSize(const material.Size(800, 700));
await tester.pumpAndSettle();

expect(find.text('Execute (F5)'), findsNothing);
expect(find.text('Explain'), findsNothing);
expect(find.byKey(const material.ValueKey('run_script')), findsOneWidget);
expect(find.byKey(const material.ValueKey('explain_query')), findsOneWidget);
expect(find.byTooltip('Show the query plan'), findsOneWidget);
expect(find.byTooltip('Execute (F5)'), findsOneWidget);
});

testWidgets('the results toolbar shows how long the query took',
timeout: _timeout, (tester) async {
final delegate = FakeSqlExecutionDelegate();
await pumpWorkspace(tester, delegate, initialSql: 'SELECT n FROM t');
expect(find.byKey(const material.ValueKey('result_elapsed')), findsNothing);

await tester.tap(find.widgetWithText(OutlineButton, 'Execute (F5)'));
await settle(tester);

expect(find.byKey(const material.ValueKey('result_elapsed')), findsOneWidget);
});

test('the elapsed badge reads ms, s and min', () {
expect(formatResultElapsed(const Duration(milliseconds: 14)), '14 ms');
expect(formatResultElapsed(const Duration(milliseconds: 1234)), '1.2 s');
expect(formatResultElapsed(const Duration(seconds: 125)), '2 min 5 s');
});

testWidgets('no Query caption and no Data Output bar above the results',
timeout: _timeout, (tester) async {
await pumpWorkspace(tester, FakeSqlExecutionDelegate());
Expand Down
Loading