From 969f44175372b0e5c647073e1d36fe744fb115c4 Mon Sep 17 00:00:00 2001 From: rarnu Date: Thu, 7 May 2026 01:04:52 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E5=AF=B9SQL=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0Ctrl(Mac=E4=B8=8B=E6=98=AFCmd)+=E5=B7=A6?= =?UTF-8?q?=E9=94=AE=E7=82=B9=E5=87=BB=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E8=A1=A8=E5=90=8D=E5=8F=AF=E4=BB=A5=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E5=BB=BA=E8=A1=A8DDL=EF=BC=8C=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E5=88=97=E5=90=8D=E5=8F=AF=E4=BB=A5=E6=9F=A5=E7=9C=8B=E5=88=97?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add: 对SQL编辑器增加Ctrl(Mac下是Cmd)+左键点击的功能,点击表名可以查看建表DDL,点击列名可以查看列信息 * add: 修复联表情况下获取列信息的问题 * fix: 修复联表场景下的SQL语句提示 * remove: 移除不需要的调试打印 * add: 补充'列详情'多语言字段 * remove: 移除不必要的函数 * remove: 移除不必要的导入 * add: 目前会分析SQL语句内引用的表,并且只加载对应的表内的列信息,不再对全表做列信息拉取 --- src/App.vue | 22 ++ src/components/editor/ColumnInfoPanel.vue | 113 +++++++++ src/components/editor/QueryEditor.vue | 226 ++++++++++++++++-- src/components/layout/ContentArea.vue | 96 +++++++- src/i18n/locales/en.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + src/lib/sqlCompletion.ts | 267 ++++++++++++++++++++-- src/lib/sqlNavigation.ts | 174 ++++++++++++++ 8 files changed, 863 insertions(+), 37 deletions(-) create mode 100644 src/components/editor/ColumnInfoPanel.vue create mode 100644 src/lib/sqlNavigation.ts diff --git a/src/App.vue b/src/App.vue index e5b66c7232..0eb17ec346 100644 --- a/src/App.vue +++ b/src/App.vue @@ -227,6 +227,27 @@ async function openConnectionQuery(connectionId: string) { } } +async function onClickTable(tableName: string) { + const tab = activeTab.value; + if (!tab) return; + const connectionId = tab.connectionId; + const database = tab.database; + + // Parse schema.table if needed + const [schema, rawTableName] = tableName.includes(".") ? tableName.split(".") : [database, tableName]; + + try { + await connectionStore.ensureConnected(connectionId); + const ddl = await api.getTableDdl(connectionId, database, schema || database, rawTableName); + + // Create a new tab with the DDL + const tabId = queryStore.createTab(connectionId, database, `DDL - ${rawTableName}`); + queryStore.updateSql(tabId, ddl); + } catch (e: any) { + toast(`Failed to get table DDL: ${e?.message || String(e)}`, 5000); + } +} + async function changeActiveConnection(connectionId: string) { const tab = activeTab.value; if (!tab) return; @@ -458,6 +479,7 @@ onUnmounted(() => { @paginate="onPaginate" @sort="onSort" @execute-sql="onExecuteSql" + @click-table="onClickTable" /> +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); +} + + + diff --git a/src/components/editor/QueryEditor.vue b/src/components/editor/QueryEditor.vue index 6e4e817c30..40a63641f3 100644 --- a/src/components/editor/QueryEditor.vue +++ b/src/components/editor/QueryEditor.vue @@ -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(); @@ -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(); + 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>>(); - 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(); + 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) => { diff --git a/src/components/layout/ContentArea.vue b/src/components/layout/ContentArea.vue index 88fc8b1223..dd3b6f6041 100644 --- a/src/components/layout/ContentArea.vue +++ b/src/components/layout/ContentArea.vue @@ -1,5 +1,5 @@