diff --git a/src/App.vue b/src/App.vue index f4ec56c8b5..26ea24c132 100644 --- a/src/App.vue +++ b/src/App.vue @@ -45,6 +45,7 @@ import { getCurrentWebview } from "@tauri-apps/api/webview"; import * as api from "@/lib/tauri"; import { resolveExecutableSql } from "@/lib/sqlExecutionTarget"; import type { SqlFormatDialect } from "@/lib/sqlFormatter"; +import { isCloseTabShortcut, isExecuteSqlShortcut } from "@/lib/keyboardShortcuts"; const { t } = useI18n(); const connectionStore = useConnectionStore(); @@ -508,12 +509,27 @@ function openLatestRelease() { open(url); } +function isQueryEditorTarget(target: EventTarget | null): boolean { + return target instanceof Element && !!target.closest("[data-query-editor-root]"); +} + function handleKeydown(e: KeyboardEvent) { - if (e.metaKey && e.key === "w") { + if (isCloseTabShortcut(e)) { e.preventDefault(); if (queryStore.activeTabId) { queryStore.closeTab(queryStore.activeTabId); } + return; + } + + if ( + activeTab.value?.mode === "query" + && isExecuteSqlShortcut(e) + && isQueryEditorTarget(e.target) + ) { + e.preventDefault(); + e.stopPropagation(); + tryExecute(); } } @@ -521,13 +537,13 @@ onMounted(() => { applyTheme(); connectionStore.initFromDisk(); settingsStore.initAiConfig(); - window.addEventListener("keydown", handleKeydown); + window.addEventListener("keydown", handleKeydown, true); setupFileDrop(); checkUpdates({ silent: true }); }); onUnmounted(() => { - window.removeEventListener("keydown", handleKeydown); + window.removeEventListener("keydown", handleKeydown, true); }); const DB_EXTENSIONS = [".db", ".sqlite", ".sqlite3", ".duckdb"]; diff --git a/src/components/editor/QueryEditor.vue b/src/components/editor/QueryEditor.vue index 88f2f14862..166d8b7851 100644 --- a/src/components/editor/QueryEditor.vue +++ b/src/components/editor/QueryEditor.vue @@ -212,5 +212,5 @@ onBeforeUnmount(() => { diff --git a/src/lib/keyboardShortcuts.ts b/src/lib/keyboardShortcuts.ts new file mode 100644 index 0000000000..065468cbf3 --- /dev/null +++ b/src/lib/keyboardShortcuts.ts @@ -0,0 +1,20 @@ +export interface ShortcutLikeEvent { + key: string; + metaKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + isComposing?: boolean; +} + +export function isExecuteSqlShortcut(event: ShortcutLikeEvent): boolean { + if (event.isComposing) return false; + if (event.altKey) return false; + if (!event.metaKey && !event.ctrlKey) return false; + return event.key === "Enter"; +} + +export function isCloseTabShortcut(event: ShortcutLikeEvent): boolean { + if (event.isComposing) return false; + if (!event.metaKey) return false; + return event.key.toLowerCase() === "w"; +} diff --git a/tests/keyboardShortcuts.test.ts b/tests/keyboardShortcuts.test.ts new file mode 100644 index 0000000000..da620113bb --- /dev/null +++ b/tests/keyboardShortcuts.test.ts @@ -0,0 +1,27 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { isCloseTabShortcut, isExecuteSqlShortcut } from "../src/lib/keyboardShortcuts.ts"; + +test("matches Cmd+Enter for SQL execution", () => { + assert.equal(isExecuteSqlShortcut({ key: "Enter", metaKey: true }), true); +}); + +test("matches Ctrl+Enter for SQL execution", () => { + assert.equal(isExecuteSqlShortcut({ key: "Enter", ctrlKey: true }), true); +}); + +test("ignores Enter without modifier", () => { + assert.equal(isExecuteSqlShortcut({ key: "Enter" }), false); +}); + +test("ignores composing input events", () => { + assert.equal(isExecuteSqlShortcut({ key: "Enter", metaKey: true, isComposing: true }), false); +}); + +test("matches Cmd+W for closing query tabs", () => { + assert.equal(isCloseTabShortcut({ key: "w", metaKey: true }), true); +}); + +test("ignores Ctrl+W for closing query tabs", () => { + assert.equal(isCloseTabShortcut({ key: "w", ctrlKey: true }), false); +});