diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 39e7f9acd..8fc06baa9 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -205,6 +205,11 @@ const { dangerSql, pendingDangerSql, showDangerDialog, suppressDangerConfirm, tr blockDangerousRedisCommands, }); +function requestActiveEditorExecute() { + if (contentAreaRef.value?.requestQueryEditorExecute?.()) return; + void tryExecute(); +} + const dialogs = useDialogSources(); const { getDatabaseOptions } = useDatabaseOptions(); const { openLineageTarget, openDatabaseSearchTarget, onStructureEditorSaved, openTableTarget } = useNavigationTargets(dialogs); @@ -1117,7 +1122,7 @@ function handleKeydown(e: KeyboardEvent) { if (activeTab.value?.mode === "query" && isExecuteSqlShortcut(e, shortcuts) && e.target instanceof Element && e.target.closest("[data-query-editor-root]")) { e.preventDefault(); e.stopPropagation(); - tryExecute(); + requestActiveEditorExecute(); return; } if (isModRShortcut(e) && e.target instanceof Element && contentAreaRef.value?.handleModRTarget(e.target)) { @@ -1378,7 +1383,7 @@ onUnmounted(() => { :sql-keyword-case="settingsStore.editorSettings.sqlFormatter.keywordCase" @update:explain-mode="(m: 'explain' | 'autotrace') => (explainMode = m)" @update:block-dangerous-redis-commands="(v: boolean) => (blockDangerousRedisCommands = v)" - @execute="tryExecute($event)" + @execute="requestActiveEditorExecute()" @cancel="cancelActiveExecution()" @explain="tryExplain()" @format-sql="formatActiveSql" diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index e1333f687..04f36991a 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -6,9 +6,11 @@ import type { CompletionContext } from "@codemirror/autocomplete"; import type { EditorView as EditorViewType } from "@codemirror/view"; import { search as cmSearch } from "@codemirror/search"; import EditorSearchPanel from "./EditorSearchPanel.vue"; +import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { copyToClipboard } from "@/lib/clipboard"; -import { resolveExecutableSql, type SqlExecutionSnapshot } from "@/lib/sqlExecutionTarget"; +import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sqlExecutionTarget"; +import { buildExecutionCandidates, supportsExecutionTargetPicker } from "@/lib/sqlStatementRanges"; import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter"; import { formatMongoShellText } from "@/lib/mongoFormatter"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -66,7 +68,7 @@ const emit = defineEmits<{ selectionChange: [value: string]; cursorChange: [pos: number]; formatError: [message: string]; - execute: [snapshot: SqlExecutionSnapshot]; + execute: [source: SqlExecutionOverride]; save: []; clickTable: [tableName: string]; viewTableData: [tableName: string]; @@ -150,6 +152,13 @@ const contextTableName = ref(null); const hasSelectedSql = computed(() => selectedSql.value.trim().length > 0); const canCopySelectedSql = computed(() => selectedSql.value.length > 0); const canExecuteContextSql = computed(() => executableSql.value.trim().length > 0); + +// Execution target picker state +const pickerVisible = ref(false); +const pickerCandidates = ref([]); +const pickerActiveIndex = ref(0); +const pickerAnchor = ref<{ left: number; top: number }>(); + const executeContextMenuLabel = computed(() => t(hasSelectedSql.value ? "editor.contextMenu.executeSelection" : "editor.contextMenu.executeCurrent")); interface EditorGestureEvent extends Event { @@ -184,6 +193,9 @@ let codeMirrorRedo: typeof import("@codemirror/commands").redo | null = null; let codeMirrorSelectAll: typeof import("@codemirror/commands").selectAll | null = null; let codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").insertNewlineKeepIndent | null = null; let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType | null = null; +let setPreviewRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null; +let previewRangeComp: import("@codemirror/state").Compartment | null = null; +let buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | null = null; let semanticDiagnostics: SqlSemanticDiagnostic[] = []; let semanticDiagnosticTimer: ReturnType | null = null; let semanticDiagnosticRunId = 0; @@ -289,11 +301,77 @@ function handleTab(view: EditorViewType): boolean { return true; } -function executeCurrentSql() { - if (view.value) emit("execute", sqlExecutionSnapshotFromView(view.value)); +function requestExecute() { + const currentView = view.value; + if (!currentView) return false; + const selection = currentView.state.selection.main; + if (!selection.empty) { + // Has manual selection → execute directly, skip picker. + emit("execute", sqlExecutionSnapshotFromView(currentView)); + return true; + } + if (!supportsExecutionTargetPicker(props.databaseType)) { + emit("execute", sqlExecutionSnapshotFromView(currentView)); + return true; + } + // No selection → show the execution target picker. + const doc = currentView.state.doc.toString(); + const cursorPos = selection.head; + const candidates = buildExecutionCandidates(doc, cursorPos, props.databaseType); + if (candidates.length === 0) return false; + closePicker(); + pickerCandidates.value = candidates; + pickerActiveIndex.value = 0; + pickerAnchor.value = executionPickerAnchor(currentView, cursorPos, candidates.length); + pickerVisible.value = true; + setPreviewRange({ from: candidates[0].from, to: candidates[0].to }); return true; } +function executionPickerAnchor(currentView: EditorViewType, cursorPos: number, candidateCount: number): { left: number; top: number } | undefined { + const cursorRect = currentView.coordsAtPos(cursorPos); + const rootRect = editorRef.value?.getBoundingClientRect(); + if (!cursorRect || !rootRect) return undefined; + + const verticalGap = 8; + const pickerHeight = 40 + Math.max(1, candidateCount) * 36; + const verticalMargin = 12; + const left = rootRect.width / 2; + const cursorBottom = cursorRect.bottom - rootRect.top; + const maxTop = Math.max(verticalMargin, rootRect.height - pickerHeight - verticalMargin); + const top = Math.min(cursorBottom + verticalGap, maxTop); + + return { left, top }; +} + +function setPreviewRange(range: { from: number; to: number } | null) { + if (!view.value || !setPreviewRangeEffect) return; + view.value.dispatch({ + effects: setPreviewRangeEffect.of(range), + }); +} + +function onPickerActiveIndexChange(index: number) { + pickerActiveIndex.value = index; + const candidate = pickerCandidates.value[index]; + if (candidate) { + setPreviewRange({ from: candidate.from, to: candidate.to }); + } +} + +function onPickerConfirm(candidate: SqlExecutionCandidate) { + closePicker(); + emit("execute", candidate.sql); +} + +function closePicker() { + pickerVisible.value = false; + pickerAnchor.value = undefined; + setPreviewRange(null); + // Restore focus to the CodeMirror editor. + view.value?.focus(); +} + function syncContextMenuState(currentView: EditorViewType) { selectedSql.value = selectedSqlFromView(currentView); executableSql.value = executableSqlFromView(currentView); @@ -338,7 +416,7 @@ function clearTableNavigationHoverOnModifierRelease(event: KeyboardEvent) { function executeFromContextMenu() { if (!canExecuteContextSql.value) return; - executeCurrentSql(); + requestExecute(); focusEditor(); } @@ -418,7 +496,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") }, ...binding(shortcuts.find, openSearch), ...binding(shortcuts.replace, openReplace), - ...binding(shortcuts.executeSql, executeCurrentSql), + ...binding(shortcuts.executeSql, requestExecute), ...binding(shortcuts.saveSql, () => { emit("save"); return true; @@ -1607,6 +1685,7 @@ onMounted(async () => { runKeymapComp = new Compartment(); completionComp = new Compartment(); diagnosticComp = new Compartment(); + previewRangeComp = new Compartment(); setSqlDiagnosticsEffect = StateEffect.define(); codeMirrorCompletionStatus = completionStatus; codeMirrorAcceptCompletion = acceptCompletion; @@ -1666,6 +1745,28 @@ onMounted(async () => { return [field, diagnosticTheme]; }; + setPreviewRangeEffect = StateEffect.define<{ from: number; to: number } | null>(); + buildPreviewRangeExtension = () => { + const effectType = setPreviewRangeEffect!; + const field = StateField.define({ + create() { + return Decoration.none; + }, + update(decorations, transaction) { + for (const effect of transaction.effects) { + if (effect.is(effectType)) { + const range = effect.value; + if (!range) return Decoration.none; + return Decoration.set([Decoration.mark({ class: "cm-db-execution-preview" }).range(range.from, range.to)]); + } + } + return decorations; + }, + provide: (f) => EditorView.decorations.from(f), + }); + return field; + }; + buildSqlSignatureExtension = () => showTooltip.compute(["doc", "selection"], (currentState) => { const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head); @@ -1781,6 +1882,7 @@ onMounted(async () => { hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)), buildSqlSignatureExtension(), diagnosticComp.of(buildSqlDiagnosticExtension()), + previewRangeComp.of(buildPreviewRangeExtension()), Prec.highest( keymap.of([ ...closeBracketsKeymap, @@ -2277,7 +2379,7 @@ function scrollCursorIntoView() { }); } -defineExpose({ openSearch, openReplace, scrollCursorIntoView }); +defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute }); @@ -2305,6 +2408,10 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView }); cursor: pointer; } +:deep(.cm-db-execution-preview) { + background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35)); +} + :deep(.cm-foldMarker-svg) { display: inline-flex; align-items: center; diff --git a/apps/desktop/src/components/editor/SqlExecutionTargetPicker.vue b/apps/desktop/src/components/editor/SqlExecutionTargetPicker.vue new file mode 100644 index 000000000..0faa3e251 --- /dev/null +++ b/apps/desktop/src/components/editor/SqlExecutionTargetPicker.vue @@ -0,0 +1,118 @@ + + + diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 9e17a45e7..aeebb9f47 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -517,7 +517,11 @@ function handleModRTarget(target: Element): boolean { return false; } -defineExpose({ focusSearch, refreshData, handleModRTarget }); +function requestQueryEditorExecute() { + return queryEditorRef.value?.requestExecute(); +} + +defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExecute });