From 9f6aa34151f373993a86f76fca86cf4e7790670d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 10:19:13 +0300 Subject: [PATCH 1/3] fix(shortcuts): Ctrl+Enter outside the editor runs the statement; Shift+Alt+F formats; Ctrl+, opens Preferences (#1148) - The global key handler sent Ctrl/Cmd+Enter, Ctrl/Cmd+R and F5 to the same script run. Outside the editor (results grid, toolbar) Ctrl+Enter now runs the selection or the statement at the cursor, as inside it; F5 and Ctrl/Cmd+Shift+Enter run the script. SqlEditorCommandBridge gets onExecuteStatement / invokeExecuteStatement for that. - Shift+Alt+F was named in the reference, the Format button tooltip and the palette, but bound nowhere. The SQL workspace binds it. - Ctrl+, worked only through the macOS menu bar; the global handler opens Preferences on Linux and Windows too. --- .../actions/sql_editor_command_bridge.dart | 17 ++++++++++ lib/features/main_screen/main_screen.dart | 29 ++++++++++++---- .../workspace/generic_sql_workspace.dart | 20 ++++++++--- .../sql_editor_command_bridge_test.dart | 33 +++++++++++++++++++ 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/lib/core/actions/sql_editor_command_bridge.dart b/lib/core/actions/sql_editor_command_bridge.dart index 01182de3..82e4ef8c 100644 --- a/lib/core/actions/sql_editor_command_bridge.dart +++ b/lib/core/actions/sql_editor_command_bridge.dart @@ -14,6 +14,7 @@ class SqlEditorCommandBridge { VoidCallback? _onOpen; VoidCallback? _onSave; VoidCallback? _onExecute; + VoidCallback? _onExecuteStatement; VoidCallback? _onCloseTab; VoidCallback? _onNextTab; VoidCallback? _onPrevTab; @@ -36,6 +37,7 @@ class SqlEditorCommandBridge { required VoidCallback onOpen, required VoidCallback onSave, VoidCallback? onExecute, + VoidCallback? onExecuteStatement, VoidCallback? onCloseTab, VoidCallback? onNextTab, VoidCallback? onPrevTab, @@ -48,6 +50,7 @@ class SqlEditorCommandBridge { _onOpen = onOpen; _onSave = onSave; _onExecute = onExecute; + _onExecuteStatement = onExecuteStatement; _onCloseTab = onCloseTab; _onNextTab = onNextTab; _onPrevTab = onPrevTab; @@ -64,6 +67,7 @@ class SqlEditorCommandBridge { _onOpen = null; _onSave = null; _onExecute = null; + _onExecuteStatement = null; _onCloseTab = null; _onNextTab = null; _onPrevTab = null; @@ -108,6 +112,18 @@ class SqlEditorCommandBridge { pendingAction = SqlEditorPendingAction.executeQuery; } + /// Runs the selection or the statement at the cursor (Ctrl/Cmd+Enter); + /// [invokeExecute] runs the whole script. Falls back to [invokeExecute] when + /// the workspace has no statement mode. + void invokeExecuteStatement() { + final statement = _onExecuteStatement; + if (statement != null) { + statement(); + return; + } + invokeExecute(); + } + void invokeCloseTab() { _onCloseTab?.call(); } @@ -176,6 +192,7 @@ class SqlEditorCommandBridge { _onOpen = null; _onSave = null; _onExecute = null; + _onExecuteStatement = null; _onCloseTab = null; _onNextTab = null; _onPrevTab = null; diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 381cc86c..4287c746 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -156,7 +156,9 @@ class _MainScreenState extends State { final physical = event.physicalKey; final logical = event.logicalKey; - // 1. Execute SQL: F5, or (Ctrl/Cmd + Enter), or (Ctrl/Cmd + R) + // 1. Execute SQL, as in the editor: Ctrl/Cmd+Enter and Ctrl/Cmd+R run the + // selection or the statement at the cursor; F5 and Ctrl/Cmd+Shift+Enter + // run the whole script. final isEnter = physical == PhysicalKeyboardKey.enter || physical == PhysicalKeyboardKey.numpadEnter || logical == LogicalKeyboardKey.enter || @@ -167,13 +169,28 @@ class _MainScreenState extends State { logical == LogicalKeyboardKey.keyR || logical == const LogicalKeyboardKey(0x0000043a); // Russian 'к' - if ((isCmdOrCtrl && isEnter) || - isF5 || - (isCmdOrCtrl && !isShift && !isAlt && isKeyR)) { - if (SqlEditorCommandBridge.instance.canExecute) { + final runScript = isF5 || (isCmdOrCtrl && isShift && !isAlt && isEnter); + final runStatement = isCmdOrCtrl && + !isShift && + !isAlt && + (isEnter || isKeyR); + if ((runScript || runStatement) && + SqlEditorCommandBridge.instance.canExecute) { + if (runScript) { SqlEditorCommandBridge.instance.invokeExecute(); - return true; + } else { + SqlEditorCommandBridge.instance.invokeExecuteStatement(); } + return true; + } + + // Preferences: Ctrl+, / Cmd+, (Physical comma, Logical comma, Russian 'б') + final isComma = physical == PhysicalKeyboardKey.comma || + logical == LogicalKeyboardKey.comma || + logical == const LogicalKeyboardKey(0x00000431); // Russian 'б' + if (isCmdOrCtrl && !isShift && !isAlt && isComma) { + unawaited(showPreferencesDialog(context)); + return true; } // 2. Toggle Left Sidebar: Ctrl+B / Cmd+B (Physical B, Logical B, Russian 'и') diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 151b1f93..a08601cd 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -219,6 +219,13 @@ class GenericSqlWorkspaceState extends material.State { super.dispose(); } + void _formatActive() { + if (_activeSession.running) return; + _activeSession.formatSql(); + invalidatePane(_activeSession); + setState(() {}); + } + void _registerSqlEditorCommands() { if (!mounted) return; SqlEditorCommandBridge.instance.register( @@ -229,16 +236,17 @@ class GenericSqlWorkspaceState extends material.State { onExecute: () { if (!_activeSession.running) unawaited(execute(_activeSession)); }, + onExecuteStatement: () { + if (!_activeSession.running) { + unawaited(execute(_activeSession, true)); + } + }, onCloseTab: () { if (_sessions.length > 1) unawaited(closeTab(_activeSessionIndex)); }, onNextTab: nextTab, onPrevTab: prevTab, - onFormat: () { - _activeSession.formatSql(); - invalidatePane(_activeSession); - setState(() {}); - }, + onFormat: _formatActive, onClear: () { _activeSession.clearSql(); invalidatePane(_activeSession); @@ -1201,6 +1209,8 @@ class GenericSqlWorkspaceState extends material.State { const material.SingleActivator(LogicalKeyboardKey.keyR, meta: true): () { if (!_activeSession.running) unawaited(execute(_activeSession, true)); }, + const material.SingleActivator(LogicalKeyboardKey.keyF, shift: true, alt: true): + _formatActive, }, child: material.Focus( autofocus: true, diff --git a/test/core/actions/sql_editor_command_bridge_test.dart b/test/core/actions/sql_editor_command_bridge_test.dart index 7b1630bc..6f752500 100644 --- a/test/core/actions/sql_editor_command_bridge_test.dart +++ b/test/core/actions/sql_editor_command_bridge_test.dart @@ -50,4 +50,37 @@ void main() { bridge.unregister(connectionId: 3); expect(bridge.isActive, isFalse); }); + + // #1148: outside the editor, Ctrl/Cmd+Enter runs the statement at the + // cursor and F5 runs the script, as inside it. + test('invokeExecuteStatement runs the statement, invokeExecute the script', + () { + final ran = []; + SqlEditorCommandBridge.instance.register( + connectionId: 7, + onNew: () {}, + onOpen: () {}, + onSave: () {}, + onExecute: () => ran.add('script'), + onExecuteStatement: () => ran.add('statement'), + ); + + SqlEditorCommandBridge.instance.invokeExecuteStatement(); + SqlEditorCommandBridge.instance.invokeExecute(); + expect(ran, ['statement', 'script']); + }); + + test('without a statement mode, invokeExecuteStatement runs the script', () { + final ran = []; + SqlEditorCommandBridge.instance.register( + connectionId: 7, + onNew: () {}, + onOpen: () {}, + onSave: () {}, + onExecute: () => ran.add('script'), + ); + + SqlEditorCommandBridge.instance.invokeExecuteStatement(); + expect(ran, ['script']); + }); } From 9b41a9f0e82a284f421be2ccbf2ccee59d240759 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 10:19:13 +0300 Subject: [PATCH 2/3] fix(shortcuts): the reference matches the bindings, and a test keeps it so (#1148) - F5 was listed as 'Refresh schema / connection'; nothing refreshes the tree on F5. It runs the script in the SQL editor and reloads the table browser: listed as an alternative of Execute all statements and as Refresh table data. - Ctrl/Cmd+R is listed with Execute query / selection, Ctrl/Cmd+E (edit mode), Ctrl/Cmd+P (palette) and Ctrl/Cmd+K (quick switcher) are added. - ShortcutItem.alternativeKeys: other combinations for the same action, shown after 'or' and found by the keycap search. - A workspace test checks every SQL Editor combination of the reference against the workspace's bindings; Shift+Alt+F formats the active tab. --- lib/features/settings/preferences_dialog.dart | 3 +- .../preferences_shortcuts_section.dart | 123 ++++++++++++------ .../preferences_shortcuts_section_test.dart | 6 +- .../generic_sql_workspace_shortcuts_test.dart | 87 +++++++++++++ 4 files changed, 177 insertions(+), 42 deletions(-) diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 98c35167..0747faa0 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -204,7 +204,8 @@ class PreferencesDialogContentState final matching = PreferencesShortcutsSection.allShortcuts.where( (s) => s.action.toLowerCase().contains(q) || - s.keys.any((k) => k.toLowerCase().contains(q)), + s.allKeys.any( + (combo) => combo.any((k) => k.toLowerCase().contains(q))), ); count += matching.length; case PreferencesCategory.about: diff --git a/lib/features/settings/preferences_shortcuts_section.dart b/lib/features/settings/preferences_shortcuts_section.dart index 26ad9d2d..629b4b94 100644 --- a/lib/features/settings/preferences_shortcuts_section.dart +++ b/lib/features/settings/preferences_shortcuts_section.dart @@ -8,13 +8,20 @@ class ShortcutItem { required this.category, required this.action, required this.keys, + this.alternativeKeys = const [], this.description, }); final String category; final String action; final List keys; + + /// Other key combinations bound to the same action, shown after "or". + final List> alternativeKeys; final String? description; + + /// [keys] and then every [alternativeKeys] combination. + List> get allKeys => [keys, ...alternativeKeys]; } /// Interactive shortcuts reference table grouped by category with OS-aware keycaps. @@ -32,12 +39,18 @@ class PreferencesShortcutsSection extends material.StatefulWidget { category: 'SQL Editor', action: 'Execute query / selection', keys: [_modKey, 'Enter'], + alternativeKeys: [ + [_modKey, 'R'], + ], description: 'Runs statement under cursor or selected text', ), ShortcutItem( category: 'SQL Editor', action: 'Execute all statements', keys: [_modKey, 'Shift', 'Enter'], + alternativeKeys: const [ + ['F5'], + ], description: 'Runs the script one statement at a time, stops at the first error', ), ShortcutItem( @@ -126,6 +139,18 @@ class PreferencesShortcutsSection extends material.StatefulWidget { keys: ['Right Click', 'Filter'], description: 'Filters grid by value matching selected cell', ), + const ShortcutItem( + category: 'Data Grid', + action: 'Refresh table data', + keys: ['F5'], + description: 'Reloads the rows in the table browser', + ), + ShortcutItem( + category: 'Data Grid', + action: 'Toggle edit mode', + keys: [_modKey, 'E'], + description: 'Turns cell editing on or off in the table browser', + ), // Navigation & App ShortcutItem( @@ -146,11 +171,17 @@ class PreferencesShortcutsSection extends material.StatefulWidget { keys: [_modKey, ','], description: 'Opens application settings dialog', ), - const ShortcutItem( + ShortcutItem( category: 'Navigation & App', - action: 'Refresh schema / connection', - keys: ['F5'], - description: 'Refreshes databases and table tree', + action: 'Command palette', + keys: [_modKey, 'P'], + description: 'Searches and runs any command', + ), + ShortcutItem( + category: 'Navigation & App', + action: 'Quick switcher', + keys: [_modKey, 'K'], + description: 'Jumps to a connection, database or table', ), ShortcutItem( category: 'Navigation & App', @@ -191,7 +222,8 @@ class _PreferencesShortcutsSectionState final matchAction = item.action.toLowerCase().contains(q); final matchCategory = item.category.toLowerCase().contains(q); final matchDesc = item.description?.toLowerCase().contains(q) ?? false; - final matchKeys = item.keys.any((k) => k.toLowerCase().contains(q)); + final matchKeys = item.allKeys + .any((combo) => combo.any((k) => k.toLowerCase().contains(q))); return matchAction || matchCategory || matchDesc || matchKeys; }).toList(); @@ -352,43 +384,13 @@ class _PreferencesShortcutsSectionState material.Row( mainAxisSize: material.MainAxisSize.min, children: [ - for (var j = 0; j < item.keys.length; j++) ...[ - if (j > 0) + for (var c = 0; c < item.allKeys.length; c++) ...[ + if (c > 0) material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 3), - child: const Text('+') - .muted() - .xSmall(), - ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 7, - vertical: 3, + padding: const material.EdgeInsets.symmetric(horizontal: 6), + child: const Text('or').muted().xSmall(), ), - decoration: material.BoxDecoration( - color: theme.colorScheme.background, - borderRadius: material.BorderRadius.circular(4), - border: material.Border.all( - color: theme.colorScheme.border.withValues(alpha: 0.4), - ), - boxShadow: [ - material.BoxShadow( - color: material.Colors.black.withValues(alpha: 0.08), - offset: const material.Offset(0, 1), - blurRadius: 1, - ), - ], - ), - child: material.Text( - item.keys[j], - style: material.TextStyle( - fontFamily: QueryaTypography.mono, - fontSize: 11, - fontWeight: material.FontWeight.w600, - color: theme.colorScheme.foreground, - ), - ), - ), + ..._keycaps(item.allKeys[c], theme), ], ], ), @@ -396,4 +398,45 @@ class _PreferencesShortcutsSectionState ), ); } + + List _keycaps(List keys, ThemeData theme) { + return [ + for (var j = 0; j < keys.length; j++) ...[ + if (j > 0) + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 3), + child: const Text('+').muted().xSmall(), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 7, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: theme.colorScheme.background, + borderRadius: material.BorderRadius.circular(4), + border: material.Border.all( + color: theme.colorScheme.border.withValues(alpha: 0.4), + ), + boxShadow: [ + material.BoxShadow( + color: material.Colors.black.withValues(alpha: 0.08), + offset: const material.Offset(0, 1), + blurRadius: 1, + ), + ], + ), + child: material.Text( + keys[j], + style: material.TextStyle( + fontFamily: QueryaTypography.mono, + fontSize: 11, + fontWeight: material.FontWeight.w600, + color: theme.colorScheme.foreground, + ), + ), + ), + ], + ]; + } } diff --git a/test/features/settings/preferences_shortcuts_section_test.dart b/test/features/settings/preferences_shortcuts_section_test.dart index 8d5983b9..063abba2 100644 --- a/test/features/settings/preferences_shortcuts_section_test.dart +++ b/test/features/settings/preferences_shortcuts_section_test.dart @@ -89,7 +89,11 @@ void main() { await tester.pumpWidget(buildTestWidget(searchQuery: 'F5')); await tester.pumpAndSettle(); - expect(find.text('Refresh schema / connection'), findsOneWidget); + // F5 runs the script in the SQL editor and reloads the table browser; + // it does not refresh the connection tree (#1148). + expect(find.text('Execute all statements'), findsOneWidget); + expect(find.text('Refresh table data'), findsOneWidget); + expect(find.text('Refresh schema / connection'), findsNothing); expect(find.text('Execute query / selection'), findsNothing); }); diff --git a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart index c9c25ef8..95ffb268 100644 --- a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart +++ b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart @@ -11,6 +11,7 @@ import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/erd/erd_catalog.dart'; 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/sql_execution_delegate.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' show OutlineButton; @@ -537,4 +538,90 @@ void main() { expect(editor.executed, isEmpty, reason: 'the editor session must not carry the catalog reads'); }); + + testWidgets('Shift+Alt+F formats the active tab', timeout: _timeout, + (tester) async { + final state = await pumpWorkspace( + tester, + FakeSqlExecutionDelegate(), + initialSql: 'select n from t', + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + + expect(state.activeSession.controller.text, 'SELECT n FROM t'); + }); + + testWidgets( + 'every SQL Editor key in the shortcuts reference is bound in the ' + 'workspace (#1148)', timeout: _timeout, (tester) async { + await pumpWorkspace(tester, FakeSqlExecutionDelegate()); + + final bound = [ + for (final w in tester.widgetList( + find.descendant( + of: find.byType(GenericSqlWorkspace), + matching: find.byType(material.CallbackShortcuts), + ), + )) + ...w.bindings.keys.whereType(), + ]; + + final missing = []; + for (final item in PreferencesShortcutsSection.allShortcuts + .where((s) => s.category == 'SQL Editor')) { + for (final combo in item.allKeys) { + final want = _activatorFor(combo); + final found = bound.any((b) => + b.trigger == want.trigger && + b.control == want.control && + b.shift == want.shift && + b.alt == want.alt && + b.meta == want.meta); + if (!found) missing.add('${item.action}: ${combo.join('+')}'); + } + } + expect(missing, isEmpty); + }); +} + +/// The activator a reference row describes, e.g. `['Ctrl', 'Shift', 'Enter']`. +material.SingleActivator _activatorFor(List keys) { + var control = false, shift = false, alt = false, meta = false; + LogicalKeyboardKey? trigger; + for (final k in keys) { + switch (k) { + case 'Ctrl': + control = true; + case '⌘': + meta = true; + case 'Shift': + shift = true; + case 'Alt': + alt = true; + case 'Enter': + trigger = LogicalKeyboardKey.enter; + case 'Tab': + trigger = LogicalKeyboardKey.tab; + case 'F5': + trigger = LogicalKeyboardKey.f5; + default: + expect(k.length, 1, reason: 'unknown key label "$k"'); + trigger = LogicalKeyboardKey.findKeyByKeyId( + k.toLowerCase().codeUnitAt(0)); + } + } + expect(trigger, isNotNull, reason: 'no key in ${keys.join('+')}'); + return material.SingleActivator( + trigger!, + control: control, + shift: shift, + alt: alt, + meta: meta, + ); } From f3117bbc5171c8ad7732c49f4e6974e8c948a892 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 10:22:11 +0300 Subject: [PATCH 3/3] fix(shortcuts): showPreferencesDialog returns void; call it without unawaited The wrapper did not compile and took every test that loads the main screen with it. --- lib/features/main_screen/main_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 4287c746..6eb95e71 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -189,7 +189,7 @@ class _MainScreenState extends State { logical == LogicalKeyboardKey.comma || logical == const LogicalKeyboardKey(0x00000431); // Russian 'б' if (isCmdOrCtrl && !isShift && !isAlt && isComma) { - unawaited(showPreferencesDialog(context)); + showPreferencesDialog(context); return true; }