From c89a6b0af468de6072a0cf0f82360b0449161001 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 09:40:19 +0300 Subject: [PATCH 1/2] feat(sql-workspace): run the script one statement at a time, Ctrl+H opens history (#1148) F5 and Ctrl+Shift+Enter run the selection or the whole text as a script, one statement at a time. The first error stops the run and the status names the statement and its line; the caret moves to it. A script that succeeds reports its statement count and the rows affected. Ctrl+Enter keeps running the statement under the caret. The Run control has a menu for Run statement and Run script, each with its shortcut. Ctrl+H opens query history when the SQL workspace has focus. F11 is removed from the shortcut reference: full-screen editing is not planned. --- .../preferences_shortcuts_section.dart | 8 +- .../workspace/generic_sql_workspace.dart | 238 +++++++++++++----- .../generic_sql_workspace_shortcuts_test.dart | 62 +++++ 3 files changed, 245 insertions(+), 63 deletions(-) diff --git a/lib/features/settings/preferences_shortcuts_section.dart b/lib/features/settings/preferences_shortcuts_section.dart index dc6e51a5..26ad9d2d 100644 --- a/lib/features/settings/preferences_shortcuts_section.dart +++ b/lib/features/settings/preferences_shortcuts_section.dart @@ -38,7 +38,7 @@ class PreferencesShortcutsSection extends material.StatefulWidget { category: 'SQL Editor', action: 'Execute all statements', keys: [_modKey, 'Shift', 'Enter'], - description: 'Runs entire query script sequentially', + description: 'Runs the script one statement at a time, stops at the first error', ), ShortcutItem( category: 'SQL Editor', @@ -76,12 +76,6 @@ class PreferencesShortcutsSection extends material.StatefulWidget { keys: ['Shift', 'Alt', 'F'], description: 'Formats keywords and statement indentation', ), - const ShortcutItem( - category: 'SQL Editor', - action: 'Toggle full-screen editor', - keys: ['F11'], - description: 'Maximizes the SQL query workspace area', - ), // Data Grid ShortcutItem( diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 84d7fe2e..59c3ba46 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -510,6 +510,7 @@ class GenericSqlWorkspaceState extends material.State { List> outRows, SqlResultGridSchema gridSchema, SqlExecutionResult result, + String? scriptStatus, ) { if (!mounted || !identical(session.rows, outRows) || @@ -535,10 +536,16 @@ class GenericSqlWorkspaceState extends material.State { primaryKeys: pks, ) : null; - session.statusLine = _resultStatus(result, cols, outRows, editHint); + session.statusLine = scriptStatus != null + ? withEditHint(scriptStatus, editHint) + : _resultStatus(result, cols, outRows, editHint); }); } + /// Runs the statement at the caret ([statementAtCursor]: the selection, or + /// the one statement under the caret), or else the script: the selection, or + /// the whole text. A script runs one statement at a time and stops at the + /// first error. Future execute([ SqlQueryTabSession? targetSession, bool statementAtCursor = false, @@ -548,19 +555,18 @@ class GenericSqlWorkspaceState extends material.State { final selection = session.controller.selection; final text = session.controller.text; - String userSql; - if (selection.isValid && !selection.isCollapsed) { - userSql = selection.textInside(text).trim(); - } else if (statementAtCursor) { - final span = SqlStatementSplitter.at( - SqlStatementSplitter.spans(text), - selection.isValid ? selection.baseOffset : text.length, - ); - userSql = span == null ? '' : span.textIn(text); - } else { - userSql = text.trim(); - } - if (userSql.isEmpty) return; + final selected = selection.isValid && !selection.isCollapsed; + final scope = selected ? selection.textInside(text) : text; + final spans = _runSpans( + scope, + statementAtCursor: statementAtCursor, + selected: selected, + caret: selection.isValid ? selection.baseOffset : scope.length, + ); + if (spans.isEmpty) return; + final scopeStart = selected ? selection.start : 0; + final texts = [for (final s in spans) s.textIn(scope)]; + final runSql = statementAtCursor ? texts.single : scope.trim(); final safeToProceed = await confirmDiscardTableEditsIfDirty( context: context, @@ -573,16 +579,17 @@ class GenericSqlWorkspaceState extends material.State { session.stagingBuffer = null; } + // Checked once, over the text that will run. final confirmDestructive = await AppSettings.instance.getConfirmDestructiveOperations(); if (confirmDestructive) { - final inspection = DestructiveSqlDetector.inspect(userSql); + final inspection = DestructiveSqlDetector.inspect(runSql); if (inspection.isDestructive) { if (!mounted) return; final confirmed = await showDestructiveQueryDialog( context: context, result: inspection, - sql: userSql, + sql: runSql, connectionName: widget.connectionRow.name, ); if (confirmed != true) return; @@ -604,49 +611,109 @@ class GenericSqlWorkspaceState extends material.State { QueryaShellStatus.instance.beginBusy(message: 'Running query…'); final sw = Stopwatch()..start(); + // The grid shows the last statement that returned columns, or else the + // last statement. + SqlExecutionResult? shown; + var shownIndex = 0; + var affectedTotal = 0; + var affectedKnown = false; + String? failure; + var failedAt = 0; try { - final result = await widget.delegate.executeQuery( - userSql, - limit: _resultMaxRows, - timeout: statementTimeout, - ); - + for (var i = 0; i < texts.length; i++) { + final SqlExecutionResult result; + try { + result = await widget.delegate.executeQuery( + texts[i], + limit: _resultMaxRows, + timeout: statementTimeout, + ); + } catch (e) { + // A single statement reports its error as before. + if (texts.length == 1) rethrow; + failure = _runErrorText(e); + failedAt = i; + break; + } + if (!mounted) return; + if (result.affectedRows != null) { + affectedTotal += result.affectedRows!; + affectedKnown = true; + } + if (shown == null || + result.columns.isNotEmpty || + shown.columns.isEmpty) { + shown = result; + shownIndex = i; + } + auditSqlExecution( + connection: widget.connectionRow, + databaseName: effectiveDatabase, + sql: texts[i], + rowsAffected: result.affectedRows, + source: MutationAuditSource.sqlEditor, + ); + } if (!mounted) return; + if (failure != null) { + final at = scopeStart + spans[failedAt].start; + final line = 1 + '\n'.allMatches(text.substring(0, at)).length; + session.controller.selection = + material.TextSelection.collapsed(offset: at); + invalidatePane(session); + setState(() { + session.error = + 'Statement ${failedAt + 1} of ${texts.length} failed (line $line): $failure'; + session.running = false; + }); + QueryaShellStatus.instance.endBusy(); + return; + } + + final result = shown!; final cols = result.columns; final outRows = result.rows; + final shownSql = texts[shownIndex]; + final multi = texts.length > 1; + final scriptStatus = multi + ? '${texts.length} statements' + '${affectedKnown ? ' · $affectedTotal rows affected' : ''}' + : null; // Rows show now. The table schema (keys, types, edit hint) follows: from // the cache, or from the delegate in the background. - final cacheKey = _schemaCacheKey(userSql, cols); + final cacheKey = _schemaCacheKey(shownSql, cols); final cached = _gridSchemaCache[cacheKey]; invalidatePane(session); setState(() { session.columns = cols; session.rows = outRows; - session.affectedRows = result.affectedRows; - session.lastExecutedSql = userSql; + session.affectedRows = affectedKnown ? affectedTotal : null; + session.lastExecutedSql = shownSql; session.resultGridPrimaryKeys = const []; session.resultGridColumnDataTypes = null; session.resultGridColumnMeta = null; session.stagingBuffer?.dispose(); session.stagingBuffer = null; - session.statusLine = _resultStatus(result, cols, outRows, null); + session.statusLine = scriptStatus ?? + _resultStatus(result, cols, outRows, null); session.running = false; }); // Anything that is not a read may change table shapes: forget the cache. - if (!_isReadQuery(userSql)) _gridSchemaCache.clear(); + if (!texts.every(_isReadQuery)) _gridSchemaCache.clear(); if (cached != null) { - _applyGridSchema(session, userSql, cols, outRows, cached, result); + _applyGridSchema( + session, shownSql, cols, outRows, cached, result, scriptStatus); } else { unawaited( - widget.delegate.resolveTableSchema(userSql, cols).then( + widget.delegate.resolveTableSchema(shownSql, cols).then( (gridSchema) { if (!mounted) return; _gridSchemaCache[cacheKey] = gridSchema; - _applyGridSchema( - session, userSql, cols, outRows, gridSchema, result); + _applyGridSchema(session, shownSql, cols, outRows, gridSchema, + result, scriptStatus); }, onError: (_) {}, // The rows stay without save. ), @@ -654,7 +721,7 @@ class GenericSqlWorkspaceState extends material.State { } sw.stop(); - final duration = result.elapsed ?? sw.elapsed; + final duration = multi ? sw.elapsed : result.elapsed ?? sw.elapsed; QueryaShellStatus.instance.reportQueryResult( duration: duration, rowCount: outRows.length, @@ -662,21 +729,14 @@ class GenericSqlWorkspaceState extends material.State { message: session.statusLine, ); - auditSqlExecution( - connection: widget.connectionRow, - databaseName: effectiveDatabase, - sql: userSql, - rowsAffected: result.affectedRows, - source: MutationAuditSource.sqlEditor, - ); - + // One history entry per run, with the text that ran. final cid = widget.connectionRow.id; if (cid != null) { unawaited( LocalDb.instance.recordSqlQueryHistory( connectionId: cid, databaseName: effectiveDatabase, - sqlText: userSql, + sqlText: runSql, maxEntries: _historyMaxEntries, ), ); @@ -705,11 +765,52 @@ class GenericSqlWorkspaceState extends material.State { final afterPlainRead = _autocommit && session.error == null && _txOpen == false && - _isReadQuery(userSql); + texts.every(_isReadQuery); if (!afterPlainRead) await refreshTxStatus(); } } + /// 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; + showSqlQueryHistoryDialog( + context: context, + connectionId: cid, + databaseName: effectiveDatabase, + sqlController: session.controller, + onOpenInNewTab: (sql) => addNewTab(initialSql: sql), + ); + } + + /// The ranges of [scope] a run covers. A caret run takes the statement at + /// [caret]; a selected caret run is the selection as one statement; a script + /// is every statement. + static List _runSpans( + String scope, { + required bool statementAtCursor, + required bool selected, + required int caret, + }) { + if (statementAtCursor && selected) { + final start = scope.length - scope.trimLeft().length; + final end = scope.trimRight().length; + return end > start + ? [SqlStatementSpan(start: start, end: end, line: 1)] + : const []; + } + final all = SqlStatementSplitter.spans(scope); + if (!statementAtCursor) return all; + final at = SqlStatementSplitter.at(all, caret); + return at == null ? const [] : [at]; + } + + static String _runErrorText(Object e) => e is TimeoutException + ? 'Query timed out: ${e.message ?? e}' + : e.toString(); + /// 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 { @@ -1025,6 +1126,8 @@ class GenericSqlWorkspaceState extends material.State { const material.SingleActivator(LogicalKeyboardKey.numpadEnter, meta: true): () { if (!_activeSession.running) unawaited(execute(_activeSession, true)); }, + const material.SingleActivator(LogicalKeyboardKey.keyH, control: true): () => _openHistory(_activeSession), + const material.SingleActivator(LogicalKeyboardKey.keyH, meta: true): () => _openHistory(_activeSession), const material.SingleActivator(LogicalKeyboardKey.keyR, control: true): () { if (!_activeSession.running) unawaited(execute(_activeSession, true)); }, @@ -1113,22 +1216,45 @@ class GenericSqlWorkspaceState extends material.State { icon: material.Icons.history_rounded, size: ButtonSize.small, onPressed: widget.connectionRow.id != null && !session.running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: effectiveDatabase, - sqlController: session.controller, - onOpenInNewTab: (sql) => addNewTab(initialSql: sql), - ); - } + ? () => _openHistory(session) : null, ), - QueryaActionButton( - label: 'Execute (F5)', - icon: material.Icons.play_arrow_rounded, - loading: session.running, - onPressed: () => execute(session), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + QueryaActionButton( + key: const material.ValueKey('run_script'), + label: 'Execute (F5)', + icon: material.Icons.play_arrow_rounded, + 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, + ), + 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) QueryaActionButton( diff --git a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart index 206721c8..14c855f7 100644 --- a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart +++ b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart @@ -277,4 +277,66 @@ void main() { expect(state.activeSession.controller.text, isEmpty); }); + + testWidgets('Ctrl+Enter runs only the statement under the caret', timeout: _timeout, + (tester) async { + final delegate = FakeSqlExecutionDelegate(); + final state = await pumpWorkspace(tester, delegate, + initialSql: 'SELECT 1; SELECT 2; SELECT 3'); + // The caret sits inside "SELECT 2". + state.activeSession.controller.selection = + const TextSelection.collapsed(offset: 12); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await settle(tester); + + expect(delegate.executed, ['SELECT 2']); + }); + + testWidgets('a script stops at the first error and points at its statement', + timeout: _timeout, (tester) async { + final delegate = FakeSqlExecutionDelegate( + onExecute: (sql) { + if (sql == 'SELECT broken') throw Exception('no such column: broken'); + return const SqlExecutionResult(affectedRows: 1); + }, + ); + final state = await pumpWorkspace( + tester, + delegate, + initialSql: 'SELECT 1;\nSELECT 2;\nSELECT broken;\nSELECT 4;\nSELECT 5', + ); + + unawaited(state.execute()); + await settle(tester); + + expect(delegate.executed, ['SELECT 1', 'SELECT 2', 'SELECT broken']); + expect( + state.activeSession.error, + 'Statement 3 of 5 failed (line 3): Exception: no such column: broken', + ); + expect(state.activeSession.controller.selection.baseOffset, 20); + expect(state.activeSession.running, isFalse); + }); + + testWidgets('a script that succeeds reports its statements and rows', + timeout: _timeout, (tester) async { + final delegate = FakeSqlExecutionDelegate( + onExecute: (_) => const SqlExecutionResult(affectedRows: 2), + ); + final state = await pumpWorkspace( + tester, + delegate, + initialSql: 'INSERT INTO t VALUES (1);\nINSERT INTO t VALUES (2);\nINSERT INTO t VALUES (3)', + ); + + unawaited(state.execute()); + await settle(tester); + + expect(delegate.executed, hasLength(3)); + expect(state.activeSession.statusLine, '3 statements · 6 rows affected'); + expect(state.activeSession.affectedRows, 6); + }); } From 43ac2abbfa6fa1147efc57bb19b5fcfc07787797 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 09:43:17 +0300 Subject: [PATCH 2/2] fix(sql-workspace): keep the single-statement text for the driver, fix the shortcut tests (#1148) A script of one statement goes to the driver with its ';' as before, so the diagram's table query and its history keep the text they had. The shortcut reference tests no longer look for the removed F11 entry. --- lib/features/workspace/generic_sql_workspace.dart | 15 +++++++++------ .../preferences_shortcuts_section_test.dart | 6 +++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 59c3ba46..01a9813e 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -567,6 +567,9 @@ class GenericSqlWorkspaceState extends material.State { final scopeStart = selected ? selection.start : 0; final texts = [for (final s in spans) s.textIn(scope)]; final runSql = statementAtCursor ? texts.single : scope.trim(); + // One statement goes to the driver as the run text, with its ';' when the + // whole script is one statement. + final sent = texts.length == 1 ? [runSql] : texts; final safeToProceed = await confirmDiscardTableEditsIfDirty( context: context, @@ -624,7 +627,7 @@ class GenericSqlWorkspaceState extends material.State { final SqlExecutionResult result; try { result = await widget.delegate.executeQuery( - texts[i], + sent[i], limit: _resultMaxRows, timeout: statementTimeout, ); @@ -649,7 +652,7 @@ class GenericSqlWorkspaceState extends material.State { auditSqlExecution( connection: widget.connectionRow, databaseName: effectiveDatabase, - sql: texts[i], + sql: sent[i], rowsAffected: result.affectedRows, source: MutationAuditSource.sqlEditor, ); @@ -674,7 +677,7 @@ class GenericSqlWorkspaceState extends material.State { final result = shown!; final cols = result.columns; final outRows = result.rows; - final shownSql = texts[shownIndex]; + final shownSql = sent[shownIndex]; final multi = texts.length > 1; final scriptStatus = multi ? '${texts.length} statements' @@ -701,7 +704,7 @@ class GenericSqlWorkspaceState extends material.State { session.running = false; }); // Anything that is not a read may change table shapes: forget the cache. - if (!texts.every(_isReadQuery)) _gridSchemaCache.clear(); + if (!sent.every(_isReadQuery)) _gridSchemaCache.clear(); if (cached != null) { _applyGridSchema( @@ -765,7 +768,7 @@ class GenericSqlWorkspaceState extends material.State { final afterPlainRead = _autocommit && session.error == null && _txOpen == false && - texts.every(_isReadQuery); + sent.every(_isReadQuery); if (!afterPlainRead) await refreshTxStatus(); } } @@ -1238,7 +1241,7 @@ class GenericSqlWorkspaceState extends material.State { label: 'Run statement (${_modLabel}Enter)', icon: material.Icons.short_text_rounded, ), - QueryaActionMenuItem( + const QueryaActionMenuItem( value: false, label: 'Run script (F5)', icon: material.Icons.list_alt_rounded, diff --git a/test/features/settings/preferences_shortcuts_section_test.dart b/test/features/settings/preferences_shortcuts_section_test.dart index cf26ea59..8d5983b9 100644 --- a/test/features/settings/preferences_shortcuts_section_test.dart +++ b/test/features/settings/preferences_shortcuts_section_test.dart @@ -30,7 +30,7 @@ void main() { // Check specific shortcut actions exist expect(find.text('Execute query / selection'), findsOneWidget); expect(find.text('Format SQL statement'), findsOneWidget); - expect(find.text('Toggle full-screen editor'), findsOneWidget); + expect(find.text('Query history'), findsOneWidget); expect(find.text('Auto-fit column width'), findsOneWidget); expect(find.text('Revert staged cell changes'), findsOneWidget); expect(find.text('Filter by cell value'), findsOneWidget); @@ -86,10 +86,10 @@ void main() { }); testWidgets('filters shortcuts by keycap search query', (tester) async { - await tester.pumpWidget(buildTestWidget(searchQuery: 'F11')); + await tester.pumpWidget(buildTestWidget(searchQuery: 'F5')); await tester.pumpAndSettle(); - expect(find.text('Toggle full-screen editor'), findsOneWidget); + expect(find.text('Refresh schema / connection'), findsOneWidget); expect(find.text('Execute query / selection'), findsNothing); });