From bd828076ab175f8c58c8d69a446a539e305c6cb6 Mon Sep 17 00:00:00 2001 From: Eve <92513238+Eve-z1@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:57:04 +0800 Subject: [PATCH] feat(editor): add explain plan shortcut --- .../src/components/editor/QueryEditor.vue | 15 +++++++++++++++ .../src/components/layout/ContentArea.vue | 3 +++ .../queryEditorExecutionShortcut.spec.ts | 18 ++++++++++++++++++ .../__tests__/editor/shortcutRegistry.spec.ts | 8 ++++++++ .../lib/editor/queryEditorExecutionShortcut.ts | 4 ++-- .../desktop/src/lib/editor/shortcutRegistry.ts | 7 +++++++ 6 files changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 917015447e..8100ee59a0 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -204,6 +204,8 @@ const props = defineProps<{ autoFocus?: boolean; forceWordWrap?: boolean; hideExecutionControls?: boolean; + enableExplainShortcut?: boolean; + canExplain?: boolean; initialViewport?: { scrollTop: number; scrollLeft: number }; initialSelection?: { anchor: number; head: number }; statementExecutionMarkers?: StatementExecutionMarker[]; @@ -235,6 +237,7 @@ const emit = defineEmits<{ formatError: [message: string]; execute: [source: SqlExecutionOverride]; executeInNewResultTab: [source: SqlExecutionOverride]; + explain: []; exportQuery: [payload: { sql: string; format: "csv" | "xlsx" | "txt"; columnComments?: (string | null)[] }]; save: []; clickTable: [target: SqlObjectNavigationTarget]; @@ -2173,6 +2176,17 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") (currentView) => shouldBlockExecutionShortcut(undefined, currentView), ); const executeInNewResultTabBindings = props.hideExecutionControls ? [] : createQueryEditorExecutionShortcutBindings(shortcuts.executeSqlInNewResultTab, requestExecuteInNewResultTab, (currentView) => shouldBlockExecutionShortcut(undefined, currentView)); + const explainBindings = props.enableExplainShortcut + ? createQueryEditorExecutionShortcutBindings( + shortcuts.explainSql, + () => { + emit("explain"); + return true; + }, + (currentView) => shouldBlockExecutionShortcut(undefined, currentView), + () => !!props.canExplain, + ) + : []; const replaceShortcutBindings = createQueryEditorReplaceShortcutBindings(shortcuts.replace, openReplace); const replaceShortcutHandler = createQueryEditorReplaceShortcutHandler({ shortcut: shortcuts.replace, @@ -2221,6 +2235,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") ...replaceShortcutBindings, ...executeInNewResultTabBindings, ...executeBindings, + ...explainBindings, ...binding(shortcuts.saveSql, () => { emit("save"); return true; diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index e6ce19cf53..670144bcc7 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1298,6 +1298,8 @@ defineExpose({ :initial-viewport="activeTab.editorViewport" :initial-selection="activeTab.editorSelection" :force-word-wrap="activeTab.forceWordWrap" + enable-explain-shortcut + :can-explain="!activeTab.isExecuting && !activeTab.isExplaining && !!executableSql.trim()" @update:model-value="emit('editorUpdate', activeTab.id, $event)" @selection-change="emit('editorSelectionChange', activeTab.id, $event)" @send-selection-to-ai="emit('sendSelectionToAi', activeTab.id, $event)" @@ -1309,6 +1311,7 @@ defineExpose({ @format-error="emit('formatError', activeTab.id)" @execute="emit('execute', activeTab.id, $event)" @execute-in-new-result-tab="emit('executeInNewResultTab', activeTab.id, $event)" + @explain="emit('explain', activeTab.id)" @export-query="handleExportQuery" @save="emit('saveSql', props.activeTab.id)" @click-table="onHandleClickTable" diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorExecutionShortcut.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorExecutionShortcut.spec.ts index 271e33e48e..d0cdfabb84 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorExecutionShortcut.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorExecutionShortcut.spec.ts @@ -39,6 +39,24 @@ describe("QueryEditor execution shortcuts", () => { view.destroy(); }); + it("checks shortcut eligibility at dispatch time without rebuilding the keymap", () => { + let enabled = false; + const explain = vi.fn(() => true); + const view = createView([createQueryEditorExecutionShortcutBindings("Mod+E", explain, isComposing, () => enabled)]); + + expect(runScopeHandlers(view, executeEvent("e"), "editor")).toBe(true); + expect(explain).not.toHaveBeenCalled(); + + enabled = true; + expect(runScopeHandlers(view, executeEvent("e"), "editor")).toBe(true); + expect(explain).toHaveBeenCalledOnce(); + + enabled = false; + expect(runScopeHandlers(view, executeEvent("e"), "editor")).toBe(true); + expect(explain).toHaveBeenCalledOnce(); + view.destroy(); + }); + it("consumes the SQL shortcut without executing during IME composition", () => { const execute = vi.fn(() => true); const view = createView([createQueryEditorExecutionShortcutBindings("Mod+Enter", execute, isComposing)]); diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index cd72c5f739..25731575a6 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -152,6 +152,14 @@ describe("shortcutRegistry editor actions", () => { expect(findShortcutConflict("expandSelectStar", DEFAULT_SHORTCUT_SETTINGS.expandSelectStar, DEFAULT_SHORTCUT_SETTINGS)).toBeNull(); }); + it("registers a configurable editor shortcut for the explain plan", () => { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "explainSql"); + + expect(definition).toMatchObject({ labelKey: "toolbar.explainPlan", scope: "editor", defaultShortcut: "Mod+E" }); + expect(shortcutToCodeMirrorKey(DEFAULT_SHORTCUT_SETTINGS.explainSql)).toBe("Mod-e"); + expect(findShortcutConflict("explainSql", "Mod+E", DEFAULT_SHORTCUT_SETTINGS)).toBeNull(); + }); + it("keeps current-view search and editor find contextual on Mod+F", () => { const focusSearch = SHORTCUT_DEFINITIONS.find((item) => item.id === "focusSearch"); const find = SHORTCUT_DEFINITIONS.find((item) => item.id === "find"); diff --git a/apps/desktop/src/lib/editor/queryEditorExecutionShortcut.ts b/apps/desktop/src/lib/editor/queryEditorExecutionShortcut.ts index 792885d71b..8a4c96ccd5 100644 --- a/apps/desktop/src/lib/editor/queryEditorExecutionShortcut.ts +++ b/apps/desktop/src/lib/editor/queryEditorExecutionShortcut.ts @@ -59,14 +59,14 @@ export function createQueryEditorPostCompositionKeyGuard(options: { navigator?: * document, so the app-level shortcut fallback also checks the editor state. * This guard covers the keymap path when CodeMirror still dispatches it. */ -export function createQueryEditorExecutionShortcutBindings(shortcut: string, run: Command, isComposing: (view: EditorView) => boolean): KeyBinding[] { +export function createQueryEditorExecutionShortcutBindings(shortcut: string, run: Command, isComposing: (view: EditorView) => boolean, isEnabled: () => boolean = () => true): KeyBinding[] { if (!shortcut) return []; return [ { key: shortcutToCodeMirrorKey(shortcut), preventDefault: true, run(view) { - if (isComposing(view)) return true; + if (isComposing(view) || !isEnabled()) return true; return run(view); }, }, diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index 38aa4cde8b..c4fe2eb4cf 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -3,6 +3,7 @@ import { isMacShortcutPlatform, parseShortcutStrokes, shortcutDisplayParts } fro export type ShortcutActionId = | "executeSql" | "executeSqlInNewResultTab" + | "explainSql" | "formatSql" | "expandSelectStar" | "toggleLineComment" @@ -151,6 +152,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "editor", defaultShortcut: "Mod+\\", }, + { + id: "explainSql", + labelKey: "toolbar.explainPlan", + scope: "editor", + defaultShortcut: "Mod+E", + }, { id: "formatSql", labelKey: "settings.shortcutFormatSql",