add: 对SQL编辑器增加Ctrl(Mac下是Cmd)+左键点击的功能,点击表名可以查看建表DDL,点击列名可以查看列信息 (#122)

* add: 对SQL编辑器增加Ctrl(Mac下是Cmd)+左键点击的功能,点击表名可以查看建表DDL,点击列名可以查看列信息

* add: 修复联表情况下获取列信息的问题

* fix: 修复联表场景下的SQL语句提示

* remove: 移除不需要的调试打印

* add: 补充'列详情'多语言字段

* remove: 移除不必要的函数

* remove: 移除不必要的导入

* add: 目前会分析SQL语句内引用的表,并且只加载对应的表内的列信息,不再对全表做列信息拉取
This commit is contained in:
rarnu
2026-05-07 01:04:52 +08:00
committed by GitHub
parent 4bf4f3b77f
commit 969f441753
8 changed files with 863 additions and 37 deletions
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { X, Copy } from "lucide-vue-next";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
export interface ColumnInfo {
name: string;
table: string;
dataType?: string;
isNullable?: boolean;
columnDefault?: string | null;
isPrimaryKey?: boolean;
comment?: string | null;
extra?: string | null;
}
defineProps<{
columns: ColumnInfo[];
loading: boolean;
error?: string;
}>();
const emit = defineEmits<{
close: [];
}>();
const { t } = useI18n();
function copyText(text: string) {
navigator.clipboard.writeText(text);
}
</script>
<template>
<div class="absolute top-0 right-0 bottom-0 z-10 w-72 bg-card border-l border-border shadow-lg flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2 border-b border-border">
<span class="text-sm font-medium">{{ t("grid.columnDetails") }}</span>
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" @click="emit('close')">
<X class="h-3.5 w-3.5" />
</Button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-3 space-y-3">
<template v-if="loading">
<div class="text-xs text-muted-foreground animate-pulse">{{ t("common.loading") }}</div>
</template>
<template v-else-if="error">
<div class="text-xs text-destructive">{{ error }}</div>
</template>
<template v-else>
<!-- Column detail sections -->
<div v-for="(col, idx) in columns" :key="idx" class="space-y-2">
<!-- Column header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="font-semibold text-sm text-foreground">{{ col.name }}</span>
<Badge
v-if="col.isPrimaryKey"
variant="outline"
class="h-4 px-1 text-[10px] border-primary/40 text-primary"
>
PK
</Badge>
</div>
<Button variant="ghost" size="sm" class="h-5 w-5 p-0" @click="copyText(col.name)">
<Copy class="h-3 w-3" />
</Button>
</div>
<!-- Table origin -->
<div class="text-xs text-muted-foreground">{{ t("tabs.tooltipTable") }} {{ col.table }}</div>
<!-- Detail rows -->
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-muted-foreground">{{ t("grid.columnType") }}</span>
<span class="font-medium max-w-[180px] truncate" :title="col.dataType">
{{ col.dataType || "—" }}
</span>
</div>
<div class="flex items-center justify-between px-2 py-1">
<span class="text-muted-foreground">{{ t("structureEditor.nullable") }}</span>
<span class="font-medium">{{ col.isNullable ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
</div>
<div v-if="col.columnDefault" class="flex items-start justify-between px-2 py-1 gap-2">
<span class="text-muted-foreground shrink-0">{{ t("structureEditor.defaultValue") }}</span>
<span class="font-medium text-right max-w-[180px] truncate" :title="col.columnDefault">
{{ col.columnDefault }}
</span>
</div>
<div v-if="col.comment" class="flex items-start justify-between px-2 py-1 gap-2">
<span class="text-muted-foreground shrink-0">{{ t("structureEditor.comment") }}</span>
<span class="font-medium text-right max-w-[180px] truncate" :title="col.comment">
{{ col.comment }}
</span>
</div>
<div v-if="col.extra" class="flex items-start justify-between px-2 py-1 gap-2">
<span class="text-muted-foreground shrink-0">Extra</span>
<span class="font-medium text-right max-w-[180px] truncate" :title="col.extra">
{{ col.extra }}
</span>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
+208 -18
View File
@@ -7,7 +7,9 @@ import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { buildSqlCompletionItemsFromContext, getSqlCompletionContext } from "@/lib/sqlCompletion";
import { extractIdentifierAt, isSqlKeyword, matchTable } from "@/lib/sqlNavigation";
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
import type { SqlCompletionColumn } from "@/lib/sqlCompletion";
const props = defineProps<{
modelValue: string;
@@ -24,6 +26,9 @@ const emit = defineEmits<{
cursorChange: [pos: number];
formatError: [message: string];
execute: [sql: string];
clickTable: [tableName: string];
clickColumn: [columns: Array<{ name: string; table: string; schema?: string }>, error?: string | undefined];
closeColumnPanel: [];
}>();
const editorRef = ref<HTMLDivElement>();
@@ -36,6 +41,11 @@ let editorViewModule: typeof import("@codemirror/view") | null = null;
let fontThemeComp: import("@codemirror/state").Compartment | null = null;
let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
// Completion cache
let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = [];
// Persistent column cache keyed by "schema.table" or "table"
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
function setFontSize(size: number) {
const next = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, size));
const ss = settingsStore.editorSettings;
@@ -104,30 +114,57 @@ async function formatCurrentSql() {
async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number) {
if (!props.connectionId || !props.database) return null;
const completionContext = getSqlCompletionContext(currentState.doc.toString(), position);
const fullDoc = currentState.doc.toString();
const completionContext = getSqlCompletionContext(fullDoc, position);
const tables = await connectionStore.listCompletionTables(props.connectionId, props.database);
const columnsByTable = new Map<string, Awaited<ReturnType<typeof connectionStore.listCompletionColumns>>>();
if (completionContext.suggestColumns) {
const relatedTables = completionContext.qualifier
? completionContext.referencedTables.filter(
(table) => table.alias === completionContext.qualifier || table.name === completionContext.qualifier,
)
: completionContext.referencedTables;
// Collect referenced tables — enrich with schema from cached tables list
let refs = completionContext.referencedTables.map((rt) => {
// If no schema, look it up in the cached tables
if (!rt.schema) {
const cached = tables.find((t) => t.name.toLowerCase() === rt.name.toLowerCase());
if (cached && cached.schema) {
return { ...rt, schema: cached.schema };
}
}
return rt;
});
await Promise.all(
relatedTables.map(async (table) => {
const cacheKey = table.schema ? `${table.schema}.${table.name}` : table.name;
if (columnsByTable.has(cacheKey)) return;
// If no referenced tables but qualifier exists, infer table from tables list
if (refs.length === 0 && completionContext.qualifier) {
const q = completionContext.qualifier.toLowerCase();
const matched = tables.filter((t) => t.name.toLowerCase() === q || t.name.toLowerCase().endsWith("." + q));
refs = matched.map((t) => ({ name: t.name, schema: t.schema }));
}
await Promise.all(
refs.map(async (refTable) => {
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
if (cachedColumnsByTable.has(cacheKey)) {
return;
}
try {
const columns = await connectionStore.listCompletionColumns(
props.connectionId!,
props.database!,
table.name,
table.schema,
refTable.name,
refTable.schema,
);
columnsByTable.set(cacheKey, columns);
}),
);
cachedColumnsByTable.set(cacheKey, columns);
} catch (e) {
console.error(`[DBX] Failed to load columns for ${cacheKey}:`, e);
}
}),
);
// Build columnsByTable from persistent cache — only include columns for referenced tables
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
for (const refTable of refs) {
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
const cached = cachedColumnsByTable.get(cacheKey);
if (cached) {
columnsByTable.set(cacheKey, cached);
}
}
const items = buildSqlCompletionItemsFromContext(completionContext, {
@@ -149,6 +186,17 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E
};
}
async function refreshCompletionCache() {
if (!props.connectionId || !props.database) return;
try {
cachedTables = await connectionStore.listCompletionTables(props.connectionId, props.database);
} catch {
cachedTables = [];
}
// Clear column cache when connection/database changes
cachedColumnsByTable.clear();
}
onMounted(async () => {
if (!editorRef.value) return;
@@ -260,11 +308,139 @@ onMounted(async () => {
else if (event.deltaY > 0) zoomOut();
return true;
},
mousedown: (event: MouseEvent) => {
// Click without modifier -> close column panel
if (!event.metaKey && !event.ctrlKey) {
if (event.button === 0) {
emit("closeColumnPanel");
}
return false;
}
// Only handle Ctrl/Cmd + left click
if (event.button !== 0) return false;
const currentView = view.value;
if (!currentView || !props.connectionId || !props.database) {
return false;
}
// Use posAtCoords for accurate click position
const coords = { x: event.clientX, y: event.clientY };
const pos = currentView.posAtCoords(coords);
if (pos == null) {
return false;
}
const doc = currentView.state.doc.toString();
const identifier = extractIdentifierAt(doc, pos);
if (!identifier) {
return false;
}
if (isSqlKeyword(identifier)) {
return false;
}
// Prevent default, resolve async
event.preventDefault();
setTimeout(async () => {
try {
// Ensure table cache is populated
if (cachedTables.length === 0) {
cachedTables = await connectionStore.listCompletionTables(props.connectionId!, props.database!);
}
// 1. Check if it's a table name
const matchedTable = matchTable(identifier, cachedTables);
if (matchedTable) {
emit(
"clickTable",
matchedTable.schema ? `${matchedTable.schema}.${matchedTable.name}` : matchedTable.name,
);
return;
}
// 2. Parse SQL at click position to get referenced tables
const context = getSqlCompletionContext(doc, pos);
let referencedTables = context.referencedTables;
// Enrich referenced tables with schema from cachedTables
referencedTables = referencedTables.map((rt) => {
const cached = cachedTables.find((ct) => ct.name.toLowerCase() === rt.name.toLowerCase());
if (cached && cached.schema && !rt.schema) {
return { ...rt, schema: cached.schema };
}
return rt;
});
// Check if identifier has a qualifier (e.g., c.card_name)
const qualifierMatch = /^(.+)\.(.+)$/.exec(identifier);
const qualifier = qualifierMatch ? qualifierMatch[1] : null;
const colName = qualifierMatch ? qualifierMatch[2] : identifier;
const colLower = colName.toLowerCase();
if (referencedTables.length === 0) {
return;
}
// 3. Fetch columns — if qualifier, only check matching table; otherwise check all
const tablesToCheck = qualifier
? referencedTables.filter(
(rt) =>
rt.alias?.toLowerCase() === qualifier.toLowerCase() ||
rt.name.toLowerCase() === qualifier.toLowerCase(),
)
: referencedTables;
if (tablesToCheck.length === 0 && qualifier) {
return;
}
const matchedCols: Array<{ name: string; table: string; schema?: string }> = [];
for (const refTable of tablesToCheck) {
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
// Use persistent column cache; fetch only if missing
let cols = cachedColumnsByTable.get(cacheKey);
if (!cols) {
try {
cols = await connectionStore.listCompletionColumns(
props.connectionId!,
props.database!,
refTable.name,
refTable.schema,
);
cachedColumnsByTable.set(cacheKey, cols);
} catch {
continue;
}
}
for (const col of cols) {
if (col.name.toLowerCase() === colLower) {
matchedCols.push({
name: col.name,
table: refTable.name,
schema: col.schema || refTable.schema,
});
}
}
}
if (matchedCols.length > 0) {
emit("clickColumn", matchedCols);
}
} catch (e) {
console.error("[DBX] Ctrl+click error:", e);
}
}, 0);
return true;
},
}),
],
});
view.value = new EditorView({ state, parent: editorRef.value });
// Fetch completion data
refreshCompletionCache();
});
watch(
@@ -285,7 +461,21 @@ watch(
},
);
// Reactively apply editor settings changes (theme, font family, font size)
watch(
() => props.connectionId,
() => {
refreshCompletionCache();
},
);
watch(
() => props.database,
() => {
refreshCompletionCache();
},
);
// Reactively apply editor settings changes
watch(
() => settingsStore.editorSettings,
async (ss) => {
+94 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, defineAsyncComponent } from "vue";
import { computed, ref, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { Loader2, Square, Bot, Table2, GitBranch, BarChart3 } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
@@ -9,6 +9,8 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
import DataGrid from "@/components/grid/DataGrid.vue";
import RedisKeyBrowser from "@/components/redis/RedisKeyBrowser.vue";
import MongoDocBrowser from "@/components/mongo/MongoDocBrowser.vue";
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
@@ -41,11 +43,18 @@ const emit = defineEmits<{
paginate: [offset: number, limit: number, whereInput?: string];
sort: [column: string, direction: "asc" | "desc" | null, whereInput?: string];
executeSql: [sql: string];
clickTable: [tableName: string];
}>();
const { t } = useI18n();
const queryStore = useQueryStore();
// Column info panel state
const showColumnInfo = ref(false);
const columnInfoColumns = ref<ColumnInfo[]>([]);
const columnInfoLoading = ref(false);
const columnInfoError = ref<string | undefined>(undefined);
const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
switch (props.activeConnection?.db_type) {
case "mysql":
@@ -70,6 +79,79 @@ const hasNumericData = computed(() => {
if (!r || r.rows.length === 0) return false;
return r.columns.some((_, idx) => r.rows.some((row) => typeof row[idx] === "number"));
});
// Column info panel handlers
async function onHandleClickColumn(
matchedCols: Array<{ name: string; table: string; schema?: string }>,
errorMsg?: string,
) {
if (!props.activeTab.connectionId || !props.activeTab.database) return;
// If error or no columns, silently ignore — don't show the panel
if (errorMsg || matchedCols.length === 0) return;
columnInfoLoading.value = true;
columnInfoError.value = undefined;
try {
// Fetch full column details from API
const apiModule = await import("@/lib/api");
const results: ColumnInfo[] = [];
for (const matchedCol of matchedCols) {
const querySchema = matchedCol.schema || props.activeTab.database || "";
try {
const fullColumns = await apiModule.getColumns(
props.activeTab.connectionId,
props.activeTab.database,
querySchema,
matchedCol.table,
);
for (const col of fullColumns) {
if (col.name === matchedCol.name) {
results.push({
name: col.name,
table: matchedCol.table,
dataType: col.data_type,
isNullable: col.is_nullable,
columnDefault: col.column_default,
isPrimaryKey: col.is_primary_key,
comment: col.comment,
extra: col.extra,
});
}
}
} catch {
// Skip tables that fail
}
}
columnInfoColumns.value = results;
} catch (e: any) {
// Silently ignore errors
console.error("[DBX] Failed to fetch column info:", e);
return;
} finally {
columnInfoLoading.value = false;
showColumnInfo.value = true;
}
}
function closeColumnInfo() {
showColumnInfo.value = false;
columnInfoColumns.value = [];
columnInfoError.value = undefined;
}
function onHandleClickTable(tableName: string) {
emit("clickTable", tableName);
}
function onHandleCloseColumnPanel() {
showColumnInfo.value = false;
columnInfoColumns.value = [];
columnInfoError.value = undefined;
}
</script>
<template>
@@ -78,7 +160,7 @@ const hasNumericData = computed(() => {
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="flex-1">
<Pane :size="40" :min-size="15">
<div class="h-full flex flex-col">
<div class="h-full flex flex-col relative">
<QueryEditor
class="flex-1"
:model-value="activeTab.sql"
@@ -92,6 +174,16 @@ const hasNumericData = computed(() => {
@cursor-change="emit('editorCursorChange', $event)"
@format-error="emit('formatError')"
@execute="emit('execute')"
@click-table="onHandleClickTable"
@click-column="onHandleClickColumn"
@close-column-panel="onHandleCloseColumnPanel"
/>
<ColumnInfoPanel
v-if="showColumnInfo"
:columns="columnInfoColumns"
:loading="columnInfoLoading"
:error="columnInfoError"
@close="closeColumnInfo"
/>
</div>
</Pane>