feat(editor): add SQL execution target picker with preview decoration

When the user triggers SQL execution via shortcut/toolbar/context-menu
without a manual selection, a floating picker now surfaces the available
execution targets:

- "Current SQL" — the statement at the cursor
- "All SQL"   — the entire editor content

Each candidate is highlighted as a preview decoration in the editor as
the user navigates the picker with arrow keys. Only a single entry is
shown when the cursor statement equals the full document.

前端 SQL 范围解析器 (sqlStatementRanges.ts) 按分号拆分语句,同时跳过
字符串、引号标识符、注释和美元引用体中的分隔符,与后端 dbx-core splitter
逻辑一致。选取器支持键盘导航、鼠标悬停高亮、Enter 确认、Esc/失焦取消。

Changes:
- sqlExecutionTarget.ts: add SqlExecutionCandidate, SqlExecutionOverride types
- sqlStatementRanges.ts: frontend SQL tokeniser (new, 848 lines)
- SqlExecutionTargetPicker.vue: accessible floating listbox (new, 109 lines)
- QueryEditor.vue: preview StateEffect/StateField; requestExecute() replaces
  executeCurrentSql() to route through picker
- ContentArea.vue: expose requestQueryEditorExecute() to parent
- App.vue: toolbar + global shortcut → requestActiveEditorExecute()
- i18n: editor.executionPicker keys for all locales
- tests: 26 new unit tests for sqlStatementRanges (all 1121 tests pass)
This commit is contained in:
uicky authored and GitHub committed 2026-06-20 22:28:02 +08:00
1 parent 4590057456
commit 4d2afcad90
14 files changed
+1468 -10

No files matched your search

@@ -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<string | null>(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<SqlExecutionCandidate[]>([]);
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<SqlSemanticDiagnostic[]> | 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<typeof setTimeout> | 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<SqlSemanticDiagnostic[]>();
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 });
</script>
<template>
@@ -2296,6 +2398,7 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView });
/>
</CustomContextMenu>
<EditorSearchPanel ref="searchPanelRef" :view="view" />
<SqlExecutionTargetPicker v-if="pickerVisible" :candidates="pickerCandidates" :active-index="pickerActiveIndex" :anchor="pickerAnchor" @update:active-index="onPickerActiveIndexChange" @confirm="onPickerConfirm" @cancel="closePicker" />
</div>
</template>
@@ -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;
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { SqlExecutionCandidate } from "@/lib/sqlExecutionTarget";
import { useI18n } from "vue-i18n";
const props = defineProps<{
candidates: SqlExecutionCandidate[];
activeIndex: number;
anchor?: { left: number; top: number };
}>();
const emit = defineEmits<{
"update:activeIndex": [index: number];
confirm: [candidate: SqlExecutionCandidate];
cancel: [];
}>();
const { t } = useI18n();
const listboxRef = ref<HTMLDivElement>();
const optionRefs = ref<HTMLElement[]>([]);
const title = computed(() => t("editor.executionPicker.title"));
const pickerStyle = computed(() => {
if (!props.anchor) return undefined;
return {
left: `${props.anchor.left}px`,
top: `${props.anchor.top}px`,
transform: "translate(-50%, 0)",
};
});
function displayLabel(candidate: SqlExecutionCandidate) {
return t(`editor.executionPicker.${candidate.label}`);
}
function optionId(index: number) {
return `dbx-exec-target-${index}`;
}
watch(
() => props.activeIndex,
(index) => {
void nextTick(() => {
optionRefs.value[index]?.scrollIntoView({ block: "nearest" });
});
},
);
function setActiveIndex(index: number) {
emit("update:activeIndex", Math.max(0, Math.min(index, props.candidates.length - 1)));
}
function confirmCurrent() {
const candidate = props.candidates[props.activeIndex];
if (candidate) emit("confirm", candidate);
}
function onKeydown(event: KeyboardEvent) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setActiveIndex(props.activeIndex + 1);
break;
case "ArrowUp":
event.preventDefault();
setActiveIndex(props.activeIndex - 1);
break;
case "Enter":
event.preventDefault();
confirmCurrent();
break;
case "Escape":
event.preventDefault();
emit("cancel");
break;
}
}
function setOptionRef(index: number, el: Element | null) {
if (el instanceof HTMLElement) optionRefs.value[index] = el;
}
onMounted(() => {
listboxRef.value?.focus();
});
onBeforeUnmount(() => {
emit("cancel");
});
</script>
<template>
<div ref="listboxRef" role="listbox" tabindex="-1" class="sql-execution-picker absolute z-[9998] w-[520px] max-w-[calc(100vw-32px)] flex flex-col rounded-md border bg-popover shadow-lg outline-none" :style="pickerStyle" @keydown="onKeydown" @blur="emit('cancel')">
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-xs font-semibold">{{ title }}</div>
</div>
<div class="flex flex-col py-1">
<div
v-for="(candidate, index) in candidates"
:id="optionId(index)"
:key="candidate.kind"
role="option"
:ref="(el: any) => setOptionRef(index, el as Element | null)"
:aria-selected="index === activeIndex"
class="flex items-center gap-2 mx-1 px-2 py-1.5 rounded text-sm cursor-pointer select-none"
:class="index === activeIndex ? 'bg-primary text-primary-foreground' : 'hover:bg-accent hover:text-accent-foreground'"
@mousemove="setActiveIndex(index)"
@click="emit('confirm', candidate)"
>
<span class="shrink-0 text-xs font-medium px-1.5 py-0.5 rounded border" :class="index === activeIndex ? 'border-primary-foreground/40 bg-primary-foreground/10' : 'border-border bg-muted/50'">
{{ displayLabel(candidate) }}
</span>
<span class="truncate min-w-0 font-mono text-xs opacity-80">{{ candidate.sql }}</span>
</div>
</div>
</div>
</template>
@@ -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 });
</script>
<template>