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:
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>
|
||||
|
||||
Reference in new issue
Block a user