diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 0c87d17698..e688d2acd8 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -423,6 +423,7 @@ async function resolveActiveExecutableSql(snapshot?: SqlExecutionSnapshot) { const blockDangerousRedisCommands = ref(true); const databaseRequiredSignal = ref(0); const databaseRequiredTabId = ref(null); +const pendingToolbarExecutionSnapshot = ref(); const sqlExecutionDangerStore = useSqlExecutionDangerStore(); const productionSafetyStore = useProductionSafetyStore(); @@ -466,7 +467,19 @@ const { onExecutionStarted: (editorViewportRequestId) => contentAreaRef.value?.acceptQueryEditorExecutionViewport(editorViewportRequestId), }); -function requestActiveEditorExecute() { +function captureActiveEditorExecutionSnapshot() { + pendingToolbarExecutionSnapshot.value = contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.(); +} + +function requestActiveEditorExecute(source?: "pointer" | "keyboard") { + const snapshot = pendingToolbarExecutionSnapshot.value; + pendingToolbarExecutionSnapshot.value = undefined; + if (source === "pointer") { + if (snapshot) { + void tryExecute(snapshot); + return; + } + } if (contentAreaRef.value?.requestQueryEditorExecute?.()) return; void tryExecute(); } @@ -3088,7 +3101,8 @@ onUnmounted(() => { @commit="activeTab && queryStore.commitTransaction(activeTab.id)" @rollback="activeTab && queryStore.rollbackTransaction(activeTab.id)" @dismiss-txn-rolled-back="activeTab && (activeTab.txnAutoRolledBack = false)" - @execute="requestActiveEditorExecute()" + @execute-pointer-down="captureActiveEditorExecutionSnapshot()" + @execute="requestActiveEditorExecute($event)" @multi-execute="requestMultiDbExecute()" @cancel="cancelActiveExecution()" @explain="tryExplain()" diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 48ec694d1c..b853949599 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -870,6 +870,18 @@ function emitExecutionRequest(source: SqlExecutionOverride, openInNewResultTab = } } +/** + * Captures a manual selection before a toolbar click can cause a platform + * focus transition. The snapshot is immutable, so downstream execution keeps + * the exact SQL and source offsets that were visible when the button was + * pressed. + */ +function captureExecutionSnapshot(): SqlExecutionSnapshot | undefined { + const currentView = view.value; + if (!currentView || currentView.state.selection.main.empty) return undefined; + return sqlExecutionSnapshotFromView(currentView); +} + function requestExecute(options: RequestExecuteOptions = {}) { executionViewportOwnership.cancelPendingRequest(); const currentView = view.value; @@ -6525,6 +6537,7 @@ defineExpose({ shouldBlockExecutionShortcut, requestExecute, requestExecuteInNewResultTab, + captureExecutionSnapshot, pasteClipboardAsSqlInCondition, focusStatementRange, previewStatementRange, diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index b09e62bd9c..0c90154d2b 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1123,6 +1123,10 @@ function requestQueryEditorExecute() { return queryEditorRef.value?.requestExecute(); } +function captureQueryEditorExecutionSnapshot() { + return queryEditorRef.value?.captureExecutionSnapshot(); +} + function requestQueryEditorExecuteInNewResultTab() { return queryEditorRef.value?.requestExecuteInNewResultTab(); } @@ -1175,6 +1179,7 @@ defineExpose({ refreshQueryEditorCompletionCache, handleModRTarget, requestQueryEditorExecute, + captureQueryEditorExecutionSnapshot, requestQueryEditorExecuteInNewResultTab, shouldBlockQueryEditorExecutionShortcut, acceptQueryEditorExecutionViewport, diff --git a/apps/desktop/src/components/layout/EditorToolbar.vue b/apps/desktop/src/components/layout/EditorToolbar.vue index 6db03688a6..1fda3fb15b 100644 --- a/apps/desktop/src/components/layout/EditorToolbar.vue +++ b/apps/desktop/src/components/layout/EditorToolbar.vue @@ -43,7 +43,8 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - execute: []; + execute: [source: "pointer" | "keyboard"]; + executePointerDown: []; cancel: []; explain: []; "update:explainMode": [mode: "explain" | "autotrace"]; @@ -251,6 +252,20 @@ function databaseOptionIsProduction(database: string): boolean { if (!database || props.activeConnection?.is_production) return false; return productionContextForDatabase(props.activeConnection, database).reason === "database"; } + +function onExecutePointerDown(event: MouseEvent) { + if (props.activeTab.isExecuting || event.button !== 0) return; + emit("executePointerDown"); +} + +function onExecuteClick(event: MouseEvent) { + if (props.activeTab.isExecuting) { + emit("cancel"); + return; + } + emit("execute", event.detail > 0 ? "pointer" : "keyboard"); +} + async function changeCatalog(selectedCatalog: string) { const connection = props.activeConnection; if (!connection) return; @@ -276,8 +291,8 @@ async function changeCatalog(selectedCatalog: string) { class="h-6 w-6" :class="executeButtonClass" :disabled="activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())" - @mousedown.prevent - @click="activeTab.isExecuting ? emit('cancel') : emit('execute')" + @mousedown.prevent="onExecutePointerDown" + @click="onExecuteClick" > diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts index ed33c46fdc..3da5259eef 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts @@ -4,6 +4,7 @@ import { createQueryEditorExecutionViewportOwnership, isQueryEditorPositionVisib const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8"); +const editorToolbarSource = readFileSync(new URL("../../../components/layout/EditorToolbar.vue", import.meta.url), "utf8"); const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8"); const sqlExecutionSource = readFileSync(new URL("../../../composables/useSqlExecution.ts", import.meta.url), "utf8"); const queryStoreSource = readFileSync(new URL("../../../stores/queryStore.ts", import.meta.url), "utf8"); @@ -50,6 +51,21 @@ describe("QueryEditor execution routing", () => { expect(executeModeBranch).toBeGreaterThan(selectionBranch); }); + it("captures the toolbar selection before the click event can change focus", () => { + expect(queryEditorSource).toContain("function captureExecutionSnapshot(): SqlExecutionSnapshot | undefined"); + expect(queryEditorSource).toContain("return sqlExecutionSnapshotFromView(currentView);"); + expect(contentAreaSource).toContain("function captureQueryEditorExecutionSnapshot()"); + expect(contentAreaSource).toContain("queryEditorRef.value?.captureExecutionSnapshot();"); + expect(appSource).toContain("const pendingToolbarExecutionSnapshot = ref();"); + expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.();"); + expect(appSource).toContain('if (source === "pointer")'); + expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = undefined;"); + expect(appSource).toContain("void tryExecute(snapshot);"); + expect(appSource).toContain('@execute-pointer-down="captureActiveEditorExecutionSnapshot()"'); + expect(appSource).toContain('@execute="requestActiveEditorExecute($event)"'); + expect(editorToolbarSource).toContain("function onExecutePointerDown(event: MouseEvent)"); + }); + it("uses the opt-in blank-line fallback and otherwise reports the missing cursor statement", () => { expect(queryEditorSource).toContain("executeAllOnBlankLine: settingsStore.editorSettings.executeAllOnBlankLine"); expect(queryEditorSource).toContain('toast(t("editor.noExecutableStatementAtCursor"), 3000)'); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorFocus.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorFocus.spec.ts index fb64dc9a61..2a941f368f 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorFocus.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorFocus.spec.ts @@ -51,7 +51,7 @@ describe("QueryEditor auto focus wiring", () => { describe("QueryEditor toolbar focus", () => { it("does not move focus from the editor when clicking execute", () => { - expect(editorToolbarSource).toMatch(/:disabled="activeTab\.isCancelling[\s\S]*?@mousedown\.prevent[\s\S]*?@click="activeTab\.isExecuting \? emit\('cancel'\) : emit\('execute'\)"/); + expect(editorToolbarSource).toMatch(/:disabled="activeTab\.isCancelling[\s\S]*?@mousedown\.prevent="onExecutePointerDown"[\s\S]*?@click="onExecuteClick"/); expect(queryEditorSource).toMatch(/function requestExecute\([\s\S]*?const currentView = view\.value;[\s\S]*?currentView\.focus\(\);[\s\S]*?requestExecuteFromView\(currentView/); }); });