feat(editor): add SQL intention actions (Alt+Enter style)
This commit is contained in:
16 files changed
+1384
-20
No files matched your search
@@ -114,6 +114,8 @@ import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
|
||||
import { isSchemaAware, isSingleDatabase, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
||||
import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
|
||||
import { loadTableMetadata } from "@/lib/metadata/tableMetadataCache";
|
||||
import { analyzeIntentionActions, prepareExpandWildcardContext, buildExpandWildcardReplacement, type IntentionAction } from "@/lib/editor/sqlIntentionActions";
|
||||
import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
|
||||
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
|
||||
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, queryTableNavigationTargetAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
|
||||
@@ -366,6 +368,121 @@ function applyDelimitedListResult(result: string) {
|
||||
focusEditor();
|
||||
}
|
||||
|
||||
// ==================== Intention Popup ====================
|
||||
|
||||
interface IntentionPopupState {
|
||||
visible: boolean;
|
||||
// 直接复用 IntentionAction 类型,避免手动重声明导致 replacements 等字段丢失(TS2551)
|
||||
actions: IntentionAction[];
|
||||
position: { x: number; y: number };
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
const intentionPopup = ref<IntentionPopupState | null>(null);
|
||||
|
||||
function getIntentionActionLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "expand_wildcard":
|
||||
return t("intentionExpandWildcard");
|
||||
case "qualify_identifier":
|
||||
return t("intentionQualifyIdentifier");
|
||||
case "unqualify_identifier":
|
||||
return t("intentionUnqualifyIdentifier");
|
||||
case "batch_qualify_identifiers":
|
||||
return t("intentionBatchQualifyIdentifiers");
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
function closeIntentionPopup() {
|
||||
document.removeEventListener("keydown", onIntentionPopupKey);
|
||||
intentionPopup.value = null;
|
||||
focusEditor();
|
||||
}
|
||||
|
||||
function onIntentionPopupKey(e: KeyboardEvent) {
|
||||
if (!intentionPopup.value?.visible) return;
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
closeIntentionPopup();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
intentionPopup.value.selectedIndex = Math.min(intentionPopup.value.selectedIndex + 1, intentionPopup.value.actions.length - 1);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
intentionPopup.value.selectedIndex = Math.max(intentionPopup.value.selectedIndex - 1, 0);
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
executeIntentionAction(intentionPopup.value.actions[intentionPopup.value.selectedIndex]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function executeIntentionAction(action: IntentionPopupState["actions"][number]) {
|
||||
if (!intentionPopup.value) return;
|
||||
closeIntentionPopup();
|
||||
|
||||
const currentView = view.value;
|
||||
if (!currentView) return;
|
||||
|
||||
switch (action.kind) {
|
||||
case "expand_wildcard": {
|
||||
const sql = currentView.state.doc.toString();
|
||||
const cursor = currentView.state.selection.main.head;
|
||||
void (async () => {
|
||||
try {
|
||||
const ctx = prepareExpandWildcardContext(sql, cursor, props.databaseType, sqlBehaviorDialect());
|
||||
if (!ctx) return;
|
||||
|
||||
const replacement = await buildExpandWildcardReplacement(props.databaseType, ctx.rowSources, async (source) => {
|
||||
const schema = source.metadataTarget?.schema;
|
||||
const tableName = source.metadataTarget?.table ?? source.name;
|
||||
if (!tableName) return [];
|
||||
const result = await loadTableMetadata({
|
||||
connectionId: props.connectionId ?? "",
|
||||
database: props.database ?? "",
|
||||
schema,
|
||||
tableName,
|
||||
databaseType: props.databaseType ?? "mysql",
|
||||
force: false,
|
||||
});
|
||||
return result.metadata.columns.map((c) => c.name);
|
||||
});
|
||||
const v = view.value;
|
||||
if (!v || v.state.doc.toString() !== sql) return;
|
||||
v.dispatch({ changes: { from: ctx.starSpan.start, to: ctx.starSpan.end, insert: replacement } });
|
||||
} catch {
|
||||
// metadata load failed
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
case "qualify_identifier":
|
||||
case "unqualify_identifier":
|
||||
currentView.dispatch({
|
||||
changes: { from: action.span.start, to: action.span.end, insert: action.replacement },
|
||||
});
|
||||
break;
|
||||
|
||||
case "batch_qualify_identifiers": {
|
||||
// 按从后往前的顺序逐一替换,避免 offset 漂移
|
||||
const reps = action.replacements ?? [];
|
||||
for (let i = reps.length - 1; i >= 0; i--) {
|
||||
const r = reps[i];
|
||||
currentView.dispatch({
|
||||
changes: { from: r.span.start, to: r.span.end, insert: r.replacement },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const executeContextMenuLabel = computed(() => t(hasSelectedSql.value ? "editor.contextMenu.executeSelection" : "editor.contextMenu.executeCurrent"));
|
||||
|
||||
interface EditorGestureEvent extends Event {
|
||||
@@ -1525,6 +1642,41 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
];
|
||||
});
|
||||
|
||||
function handleSqlIntentionActions(currentView: EditorViewType): boolean {
|
||||
if (props.readOnly) return false;
|
||||
try {
|
||||
const sql = currentView.state.doc.toString();
|
||||
const sel = currentView.state.selection.main;
|
||||
|
||||
const actions = analyzeIntentionActions({
|
||||
sql,
|
||||
cursor: sel.head,
|
||||
databaseType: props.databaseType,
|
||||
dialect: sqlBehaviorDialect(),
|
||||
selection: sel.from !== sel.to ? { from: sel.from, to: sel.to } : undefined,
|
||||
});
|
||||
|
||||
if (actions.length === 0) return false;
|
||||
|
||||
// 计算光标视口坐标,用于定位弹出菜单
|
||||
const coords = currentView.coordsAtPos(sel.head);
|
||||
if (!coords) return false;
|
||||
|
||||
// 显示弹出菜单(参考 DataGrip Alt+Enter 意图操作弹出菜单)
|
||||
intentionPopup.value = {
|
||||
visible: true,
|
||||
actions,
|
||||
position: { x: coords.right + 8, y: coords.bottom + 4 },
|
||||
selectedIndex: 0,
|
||||
};
|
||||
document.addEventListener("keydown", onIntentionPopupKey);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[SQL Intention] error:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view"))["keymap"]) {
|
||||
const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
|
||||
const Prec = codeMirrorPrec;
|
||||
@@ -1585,6 +1737,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
|
||||
if (sql.trim()) emit("sendSelectionToAi", sql);
|
||||
return true;
|
||||
}),
|
||||
...binding(shortcuts.sqlIntentionActions, handleSqlIntentionActions),
|
||||
...createQueryEditorSearchKeymap({
|
||||
openSearch,
|
||||
openReplace,
|
||||
@@ -1606,7 +1759,7 @@ function extendQueryEditorSelectionForView(currentView: EditorViewType): boolean
|
||||
const language = databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "victoriametrics" ? "text" : "sql";
|
||||
return extendQueryEditorSelection(currentView, {
|
||||
databaseType,
|
||||
dialect: props.syntaxDialect ?? props.dialect,
|
||||
dialect: sqlBehaviorDialect(),
|
||||
language,
|
||||
});
|
||||
}
|
||||
@@ -5269,6 +5422,17 @@ defineExpose({
|
||||
<EditorSearchPanel ref="searchPanelRef" :view="view" />
|
||||
<SqlExecutionTargetPicker v-if="pickerVisible" :candidates="pickerCandidates" :active-index="pickerActiveIndex" :anchor="pickerAnchor" @update:active-index="onPickerActiveIndexChange" @confirm="onPickerConfirm" @cancel="closePicker" />
|
||||
<DelimitedListDialog v-model:open="delimitedListOpen" :selected-text="delimitedListSelectedText" @confirm="applyDelimitedListResult" />
|
||||
<!-- SQL 意图操作弹出菜单(参考 DataGrip Alt+Enter) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="intentionPopup?.visible" class="intention-popup-overlay" @click.self="closeIntentionPopup">
|
||||
<div class="intention-popup" :style="{ left: intentionPopup.position.x + 'px', top: intentionPopup.position.y + 'px' }">
|
||||
<div v-for="(action, i) in intentionPopup.actions" :key="i" class="intention-popup-item" :class="{ 'intention-popup-item--active': i === intentionPopup.selectedIndex }" @click="executeIntentionAction(action)" @mouseenter="intentionPopup.selectedIndex = i">
|
||||
<span class="intention-popup-item__icon">💡</span>
|
||||
<span class="intention-popup-item__label">{{ action.label || getIntentionActionLabel(action.kind) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5482,3 +5646,54 @@ defineExpose({
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.intention-popup-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.intention-popup {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.intention-popup-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--foreground);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.intention-popup-item:hover,
|
||||
.intention-popup-item--active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.intention-popup-item__icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.intention-popup-item__label {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount, onMounted, nextTick, type Component } from "vue";
|
||||
import { ref, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
|
||||
import { Check, ChevronRight } from "@lucide/vue";
|
||||
import { shortcutDisplayKeys } from "@/lib/editor/shortcutDisplay";
|
||||
import { registerGlobalContextMenu, type ContextMenuRegistration } from "@/components/ui/customContextMenuRegistry";
|
||||
import { registerGlobalContextMenu, type ContextMenuRegistration, type ContextMenuItem } from "@/components/ui/customContextMenuRegistry";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
action?: () => void;
|
||||
disabled?: boolean | (() => boolean);
|
||||
separator?: boolean;
|
||||
icon?: Component;
|
||||
iconClass?: string;
|
||||
checked?: boolean;
|
||||
// Raw shortcut syntax such as `Mod+C` or `Shift+Alt+U`; display formatting stays in this component.
|
||||
shortcut?: string;
|
||||
variant?: "default" | "destructive";
|
||||
visible?: boolean;
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
export type { ContextMenuItem };
|
||||
|
||||
type ContextMenuItemsSource = ContextMenuItem[] | (() => ContextMenuItem[]);
|
||||
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
import type { Component } from "vue";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
action?: () => void;
|
||||
disabled?: boolean | (() => boolean);
|
||||
separator?: boolean;
|
||||
icon?: Component;
|
||||
iconClass?: string;
|
||||
checked?: boolean;
|
||||
// Raw shortcut syntax such as `Mod+C` or `Shift+Alt+U`; display formatting stays in this component.
|
||||
shortcut?: string;
|
||||
variant?: "default" | "destructive";
|
||||
visible?: boolean;
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
export type ContextMenuClose = () => void;
|
||||
|
||||
/** Marker attribute on scrollable context menu / submenu roots. */
|
||||
|
||||
Reference in new issue
Block a user