From 73f3e2cf73e9610ce50cc0b70eb2e5a6f49aecc2 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 31 May 2026 15:30:09 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(query-editor):=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=20SQL=20=E7=BC=96=E8=BE=91=E5=99=A8=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=E6=82=AC=E6=B5=AE=E4=B8=8E=E5=BF=AB=E6=8D=B7=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 美化 SQL 改为写入 Monaco undo 栈,支持 Ctrl+Z 回退到格式化前 - 新增表名字段名库名语义着色,并在元数据加载后自动刷新装饰 - 支持鼠标悬浮和 Ctrl/Cmd+Q 查看对象信息,兼容 Ctrl/Cmd 点击跳转提示 - 补充 QueryEditor 定向测试覆盖对象 hover、快捷查看和撤销行为 Refs #506 --- frontend/src/App.css | 24 + .../QueryEditor.external-sql-save.test.tsx | 187 ++++++- frontend/src/components/QueryEditor.tsx | 481 +++++++++++++++++- 3 files changed, 688 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 304e218..a3d2f0a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -655,3 +655,27 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check text-decoration-thickness: 1px; text-underline-offset: 3px; } + +.gonavi-query-editor-object-token { + color: #2563eb; +} + +.gonavi-query-editor-column-token { + color: #0f766e; +} + +.gonavi-query-editor-db-token { + color: #7c3aed; +} + +body[data-theme='dark'] .gonavi-query-editor-object-token { + color: #7dd3fc; +} + +body[data-theme='dark'] .gonavi-query-editor-column-token { + color: #5eead4; +} + +body[data-theme='dark'] .gonavi-query-editor-db-token { + color: #c4b5fd; +} diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 614aa35..1c0c1cb 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -85,12 +85,14 @@ const editorState = vi.hoisted(() => { position: { lineNumber: 1, column: 1 }, selection: null as any, providers: [] as any[], + hoverProviders: [] as any[], cursorPositionListeners: [] as Array<(event: any) => void>, mouseMoveListeners: [] as Array<(event: any) => void>, mouseDownListeners: [] as Array<(event: any) => void>, mouseLeaveListeners: [] as Array<() => void>, hasTextFocus: true, decorationIds: [] as string[], + contentHoverCalls: [] as any[], }; const offsetAt = (position: { lineNumber: number; column: number }) => { const text = state.value; @@ -142,6 +144,16 @@ const editorState = vi.hoisted(() => { }), getSelection: vi.fn(() => state.selection), getDomNode: vi.fn(() => state.domNode), + getContribution: vi.fn((id: string) => { + if (id === 'editor.contrib.contentHover') { + return { + showContentHover: vi.fn((range: any, mode: any, source: any, focus: any) => { + state.contentHoverCalls.push({ range, mode, source, focus }); + }), + }; + } + return null; + }), setSelection: vi.fn((selection: any) => { state.selection = selection; }), @@ -175,6 +187,7 @@ const editorState = vi.hoisted(() => { return state.decorationIds; }), updateOptions: vi.fn(), + pushUndoStop: vi.fn(), onDidDispose: vi.fn(), hasTextFocus: vi.fn(() => state.hasTextFocus), revealLineInCenterIfOutsideViewport: vi.fn(), @@ -205,6 +218,8 @@ vi.mock('@monaco-editor/react', () => ({ editorState.value = String(defaultValue || ''); onMount?.(editorState.editor, { editor: { setTheme: vi.fn() }, + KeyMod: { CtrlCmd: 2048 }, + KeyCode: { KeyQ: 81 }, languages: { CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 }, CompletionItemInsertTextRule: { InsertAsSnippet: 1 }, @@ -212,6 +227,10 @@ vi.mock('@monaco-editor/react', () => ({ editorState.providers.push(provider); return { dispose: vi.fn() }; }), + registerHoverProvider: vi.fn((_language: string, provider: any) => { + editorState.hoverProviders.push(provider); + return { dispose: vi.fn() }; + }), }, Range: class { startLineNumber: number; @@ -352,17 +371,20 @@ describe('QueryEditor external SQL save', () => { editorState.selection = null; editorState.domNode.style.cursor = ''; editorState.providers = []; + editorState.hoverProviders = []; editorState.cursorPositionListeners = []; editorState.mouseMoveListeners = []; editorState.mouseDownListeners = []; editorState.mouseLeaveListeners = []; editorState.hasTextFocus = true; editorState.decorationIds = []; + editorState.contentHoverCalls = []; editorState.editor.getValue.mockClear(); editorState.editor.setValue.mockClear(); editorState.editor.executeEdits.mockClear(); editorState.editor.deltaDecorations.mockClear(); editorState.editor.updateOptions.mockClear(); + editorState.editor.pushUndoStop.mockClear(); storeState.updateQueryTabDraft.mockReset(); }); @@ -507,7 +529,7 @@ describe('QueryEditor external SQL save', () => { .mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] }); backendApp.DBGetAllColumns .mockResolvedValueOnce({ success: true, data: [] }) - .mockResolvedValueOnce({ success: true, data: [] }); + .mockResolvedValueOnce({ success: true, data: [{ tableName: 'events', name: 'id', type: 'bigint', comment: '事件ID' }] }); await act(async () => { create(); @@ -531,6 +553,8 @@ describe('QueryEditor external SQL save', () => { expect(editorState.domNode.style.cursor).toBe('pointer'); const lastDecorationCall = editorState.editor.deltaDecorations.mock.calls.at(-1); expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint'); + expect(lastDecorationCall?.[1]?.[0]?.options?.hoverMessage?.value).toContain('Ctrl/Cmd + 点击打开该表'); + expect(lastDecorationCall?.[1]?.[0]?.options?.hoverMessage?.value).toContain('**表** `events`'); await act(async () => { editorState.mouseLeaveListeners[0]?.(); @@ -539,6 +563,167 @@ describe('QueryEditor external SQL save', () => { expect(editorState.editor.updateOptions).toHaveBeenLastCalledWith({ mouseStyle: 'text' }); }); + it('formats SQL through Monaco edits so beautify can be undone', async () => { + let renderer!: ReactTestRenderer; + + await act(async () => { + renderer = create(); + }); + + const formatButton = findButton(renderer, '美化'); + await act(async () => { + await formatButton.props.onClick(); + }); + + expect(editorState.editor.pushUndoStop).toHaveBeenCalledTimes(2); + expect(editorState.editor.executeEdits).toHaveBeenCalledWith( + 'gonavi-format-sql', + expect.arrayContaining([ + expect.objectContaining({ + text: expect.stringContaining('SELECT'), + }), + ]), + ); + }); + + it('shows object info via editor ctrl+q action', async () => { + editorState.value = 'select users.id from users'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ + success: true, + data: [{ tableName: 'users', name: 'id', type: 'bigint', comment: '主键ID' }], + }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const showObjectInfoAction = editorState.editor.addAction.mock.calls + .map((call: any[]) => call[0]) + .find((action: any) => action?.id === 'gonavi.queryEditor.showObjectInfo'); + expect(showObjectInfoAction).toBeTruthy(); + + editorState.position = { lineNumber: 1, column: 13 }; + await act(async () => { + showObjectInfoAction.run(); + }); + + expect(editorState.contentHoverCalls).toHaveLength(1); + expect(editorState.contentHoverCalls[0]).toEqual(expect.objectContaining({ + mode: 1, + source: 2, + focus: false, + })); + }); + + it('prefers the hovered identifier position for ctrl+q object info', async () => { + editorState.value = 'select * from user_actions'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'user_actions' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); + + const windowListeners: Record void)[]> = {}; + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + windowListeners[type] ||= []; + windowListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const showObjectInfoAction = editorState.editor.addAction.mock.calls + .map((call: any[]) => call[0]) + .find((action: any) => action?.id === 'gonavi.queryEditor.showObjectInfo'); + expect(showObjectInfoAction).toBeTruthy(); + + editorState.position = { lineNumber: 1, column: 2 }; + await act(async () => { + windowListeners.keydown?.forEach((listener) => listener({ ctrlKey: true, metaKey: false, key: 'Control' })); + editorState.mouseMoveListeners[0]?.({ + target: { position: { lineNumber: 1, column: 17 } }, + event: { + ctrlKey: true, + metaKey: false, + }, + }); + showObjectInfoAction.run(); + }); + + expect(editorState.contentHoverCalls).toHaveLength(1); + expect(messageApi.info).not.toHaveBeenCalledWith(expect.objectContaining({ + key: 'gonavi-query-editor-object-info-miss', + })); + }); + + it('adds separate object and column color decorations', async () => { + editorState.value = 'select users.id from users'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ + success: true, + data: [{ tableName: 'users', name: 'id', type: 'bigint', comment: '主键ID' }], + }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const allDecorationEntries = editorState.editor.deltaDecorations.mock.calls.flatMap((call: any[]) => call[1] || []); + expect(allDecorationEntries.some((item: any) => item?.options?.inlineClassName === 'gonavi-query-editor-object-token')).toBe(true); + expect(allDecorationEntries.some((item: any) => item?.options?.inlineClassName === 'gonavi-query-editor-column-token')).toBe(true); + }); + + it('provides hover markdown for recognized table columns', async () => { + editorState.value = 'select users.id from users'; + autoFetchState.visible = true; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ + success: true, + data: [{ tableName: 'users', name: 'id', type: 'bigint', comment: '主键ID' }], + }); + + await act(async () => { + create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const hoverProvider = editorState.hoverProviders[0]; + expect(hoverProvider).toBeTruthy(); + + const hover = hoverProvider.provideHover( + editorState.editor.getModel(), + { lineNumber: 1, column: 13 }, + ); + expect(hover?.contents?.[0]?.value).toContain('**字段** `id`'); + expect(hover?.contents?.[0]?.value).toContain('类型:`bigint`'); + expect(hover?.contents?.[0]?.value).toContain('表:`users`'); + }); + it('keeps hover underline active when ctrl/cmd is pressed repeatedly without moving the mouse', async () => { const windowListeners: Record void)[]> = {}; vi.stubGlobal('window', { diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index fab1401..e2ef27b 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import Editor, { type OnMount } from './MonacoEditor'; import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd'; import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons'; @@ -884,7 +884,17 @@ type QueryEditorNavigationTarget = | { type: 'trigger'; dbName: string; triggerName: string; tableName: string; schemaName?: string } | { type: 'routine'; dbName: string; routineName: string; routineType: string; schemaName?: string }; +type QueryEditorHoverTarget = + | { kind: 'database'; dbName: string; range: { startColumn: number; endColumn: number } } + | { kind: 'table'; dbName: string; tableName: string; schemaName?: string; comment?: string; range: { startColumn: number; endColumn: number } } + | { kind: 'view'; dbName: string; viewName: string; schemaName?: string; range: { startColumn: number; endColumn: number } } + | { kind: 'materialized-view'; dbName: string; viewName: string; schemaName?: string; range: { startColumn: number; endColumn: number } } + | { kind: 'trigger'; dbName: string; triggerName: string; tableName: string; schemaName?: string; range: { startColumn: number; endColumn: number } } + | { kind: 'routine'; dbName: string; routineName: string; routineType: string; schemaName?: string; range: { startColumn: number; endColumn: number } } + | { kind: 'column'; dbName: string; tableName: string; columnName: string; type?: string; comment?: string; schemaName?: string; range: { startColumn: number; endColumn: number } }; + const QUERY_EDITOR_IDENTIFIER_CHAR_REGEX = /[A-Za-z0-9_$`"\[\].]/; +const QUERY_EDITOR_HOVER_DELAY_MS = 1000; const findIdentifierWindowAtOffset = ( lineContent: string, @@ -927,6 +937,68 @@ const normalizeNavigationIdentifierParts = (text: string): string[] => ( .filter(Boolean) ); +const buildQueryEditorHoverMarkdown = (target: QueryEditorHoverTarget): string => { + const appendComment = (comment?: string): string => { + const normalized = normalizeCommentText(comment); + return normalized ? `\n\n${normalized}` : ''; + }; + switch (target.kind) { + case 'database': + return `**数据库**\n\n\`${target.dbName}\``; + case 'table': + return `**表** \`${target.tableName}\`\n\n库:\`${target.dbName}\`${target.schemaName ? `\n\nSchema:\`${target.schemaName}\`` : ''}${appendComment(target.comment)}`; + case 'view': + return `**视图** \`${target.viewName}\`\n\n库:\`${target.dbName}\`${target.schemaName ? `\n\nSchema:\`${target.schemaName}\`` : ''}`; + case 'materialized-view': + return `**物化视图** \`${target.viewName}\`\n\n库:\`${target.dbName}\`${target.schemaName ? `\n\nSchema:\`${target.schemaName}\`` : ''}`; + case 'trigger': + return `**触发器** \`${target.triggerName}\`\n\n库:\`${target.dbName}\`\n\n表:\`${target.tableName}\`${target.schemaName ? `\n\nSchema:\`${target.schemaName}\`` : ''}`; + case 'routine': + return `**${target.routineType === 'PROCEDURE' ? '存储过程' : '函数'}** \`${target.routineName}\`\n\n库:\`${target.dbName}\`${target.schemaName ? `\n\nSchema:\`${target.schemaName}\`` : ''}`; + case 'column': + return `**字段** \`${target.columnName}\`${target.type ? `\n\n类型:\`${target.type}\`` : ''}\n\n表:\`${target.tableName}\`\n\n库:\`${target.dbName}\`${target.schemaName ? `\n\nSchema:\`${target.schemaName}\`` : ''}${appendComment(target.comment)}`; + default: + return ''; + } +}; + +const buildQueryEditorAliasMap = ( + fullText: string, + currentDb: string, +): Record => { + const aliasMap: Record = {}; + const reserved = new Set([ + 'where', 'on', 'group', 'order', 'limit', 'having', + 'left', 'right', 'inner', 'outer', 'full', 'cross', 'join', + 'union', 'except', 'intersect', 'as', 'set', 'values', 'returning', + ]); + const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?\w+[`"]?(?:\s*\.\s*[`"]?\w+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?\w+[`"]?))?/gi; + let match: RegExpExecArray | null; + while ((match = aliasRegex.exec(fullText)) !== null) { + const tableIdent = normalizeCompletionQualifiedName(match[1] || ''); + if (!tableIdent) continue; + const parts = tableIdent.split('.'); + let dbName = currentDb || ''; + let tableName = tableIdent; + if (parts.length === 2) { + dbName = parts[0]; + tableName = parts[1]; + } else if (parts.length >= 3) { + dbName = parts[0]; + tableName = parts.slice(1).join('.'); + } + const shortTable = getCompletionQualifiedNameLastPart(tableIdent); + if (shortTable) aliasMap[shortTable.toLowerCase()] = { dbName, tableName }; + + const alias = stripCompletionIdentifierQuotes(match[2] || '').trim(); + if (!alias) continue; + const loweredAlias = alias.toLowerCase(); + if (reserved.has(loweredAlias)) continue; + aliasMap[loweredAlias] = { dbName, tableName }; + } + return aliasMap; +}; + export const resolveQueryEditorNavigationTarget = ( lineContent: string, column: number, @@ -1147,6 +1219,160 @@ export const resolveQueryEditorNavigationTarget = ( return findObjectInPriorityOrder(dbName, tableName, schemaName); }; +const resolveQueryEditorHoverTarget = ( + fullText: string, + lineContent: string, + column: number, + currentDb: string, + visibleDbs: string[], + tables: CompletionTableMeta[], + allColumns: CompletionColumnMeta[], + views: CompletionViewMeta[] = [], + materializedViews: CompletionViewMeta[] = [], + triggers: CompletionTriggerMeta[] = [], + routines: CompletionRoutineMeta[] = [], +): QueryEditorHoverTarget | null => { + const text = String(lineContent || ''); + if (!text) return null; + + const offset = Math.max(0, Number(column || 1) - 2); + const windowRange = findIdentifierWindowAtOffset(text, offset); + if (!windowRange) return null; + + const rawIdentifier = text.slice(windowRange.start, windowRange.end).trim(); + if (!rawIdentifier) return null; + + const range = { startColumn: windowRange.start + 1, endColumn: windowRange.end + 1 }; + const parts = normalizeNavigationIdentifierParts(rawIdentifier); + if (parts.length === 0 || parts.length > 3) return null; + + const findMatchingTable = (dbName: string, rawTableName: string, schemaName = ''): CompletionTableMeta | null => { + const normalizedDbName = String(dbName || '').trim().toLowerCase(); + const normalizedRawTableName = String(rawTableName || '').trim().toLowerCase(); + const normalizedSchemaName = String(schemaName || '').trim().toLowerCase(); + return tables.find((item) => { + if (String(item.dbName || '').trim().toLowerCase() !== normalizedDbName) return false; + const itemRawName = String(item.tableName || '').trim(); + const parsed = splitSidebarQualifiedName(itemRawName); + const itemObjectName = String(parsed.objectName || itemRawName).trim().toLowerCase(); + const itemSchemaName = String(parsed.schemaName || '').trim().toLowerCase(); + if (normalizedSchemaName) { + return itemSchemaName === normalizedSchemaName && (itemObjectName === normalizedRawTableName || String(itemRawName).trim().toLowerCase() === `${normalizedSchemaName}.${normalizedRawTableName}`); + } + return itemObjectName === normalizedRawTableName || String(itemRawName).trim().toLowerCase() === normalizedRawTableName; + }) || null; + }; + + const navigationTarget = resolveQueryEditorNavigationTarget( + lineContent, + column, + currentDb, + visibleDbs, + tables, + views, + materializedViews, + triggers, + routines, + ); + if (navigationTarget) { + if (navigationTarget.type === 'database') { + return { kind: 'database', dbName: navigationTarget.dbName, range }; + } + if (navigationTarget.type === 'table') { + const meta = findMatchingTable(navigationTarget.dbName, navigationTarget.tableName, navigationTarget.schemaName || ''); + return { + kind: 'table', + dbName: navigationTarget.dbName, + tableName: navigationTarget.tableName, + schemaName: navigationTarget.schemaName, + comment: meta?.comment, + range, + }; + } + if (navigationTarget.type === 'view') { + return { kind: 'view', dbName: navigationTarget.dbName, viewName: navigationTarget.viewName, schemaName: navigationTarget.schemaName, range }; + } + if (navigationTarget.type === 'materialized-view') { + return { kind: 'materialized-view', dbName: navigationTarget.dbName, viewName: navigationTarget.viewName, schemaName: navigationTarget.schemaName, range }; + } + if (navigationTarget.type === 'trigger') { + return { kind: 'trigger', dbName: navigationTarget.dbName, triggerName: navigationTarget.triggerName, tableName: navigationTarget.tableName, schemaName: navigationTarget.schemaName, range }; + } + return { kind: 'routine', dbName: navigationTarget.dbName, routineName: navigationTarget.routineName, routineType: navigationTarget.routineType, schemaName: navigationTarget.schemaName, range }; + } + + const findColumnTarget = (dbName: string, tableName: string, columnName: string): QueryEditorHoverTarget | null => { + const normalizedDbName = String(dbName || '').trim().toLowerCase(); + const normalizedTableName = String(tableName || '').trim().toLowerCase(); + const normalizedColumnName = String(columnName || '').trim().toLowerCase(); + const column = allColumns.find((item) => { + if (String(item.dbName || '').trim().toLowerCase() !== normalizedDbName) return false; + if (String(item.name || '').trim().toLowerCase() !== normalizedColumnName) return false; + const rawTable = String(item.tableName || '').trim().toLowerCase(); + const parsed = splitCompletionSchemaAndTable(item.tableName || ''); + return rawTable === normalizedTableName || String(parsed.table || '').trim().toLowerCase() === normalizedTableName; + }); + if (!column) return null; + const parsedTable = splitCompletionSchemaAndTable(column.tableName || ''); + return { + kind: 'column', + dbName: column.dbName, + tableName: column.tableName, + columnName: column.name, + type: column.type, + comment: column.comment, + schemaName: parsedTable.schema || undefined, + range, + }; + }; + + if (parts.length === 2) { + const [firstPart, secondPart] = parts; + const aliasMap = buildQueryEditorAliasMap(fullText, currentDb); + const aliasInfo = aliasMap[firstPart.toLowerCase()]; + if (aliasInfo) { + const aliasedColumn = findColumnTarget(aliasInfo.dbName, aliasInfo.tableName, secondPart); + if (aliasedColumn) return aliasedColumn; + } + const qualifiedTable = findMatchingTable(currentDb, secondPart, firstPart); + if (qualifiedTable) { + return { + kind: 'table', + dbName: qualifiedTable.dbName, + tableName: qualifiedTable.tableName, + schemaName: firstPart, + comment: qualifiedTable.comment, + range, + }; + } + } + + if (parts.length === 1) { + const [columnName] = parts; + const normalizedCurrentDb = String(currentDb || '').trim().toLowerCase(); + const directColumns = allColumns.filter((item) => + String(item.dbName || '').trim().toLowerCase() === normalizedCurrentDb + && String(item.name || '').trim().toLowerCase() === columnName.toLowerCase() + ); + if (directColumns.length === 1) { + const column = directColumns[0]; + const parsedTable = splitCompletionSchemaAndTable(column.tableName || ''); + return { + kind: 'column', + dbName: column.dbName, + tableName: column.tableName, + columnName: column.name, + type: column.type, + comment: column.comment, + schemaName: parsedTable.schema || undefined, + range, + }; + } + } + + return null; +}; + export const resolveQueryEditorNavigationDecorations = ( lineContent: string, column: number, @@ -1205,6 +1431,16 @@ export const resolveQueryEditorNavigationDecorations = ( }]; }; +const buildQueryEditorNavigationHoverMarkdown = ( + hoverTarget: QueryEditorHoverTarget | null, + actionHint: string, +): string => { + const hoverContent = hoverTarget ? buildQueryEditorHoverMarkdown(hoverTarget) : ''; + return hoverContent + ? `${hoverContent}\n\n---\n\n${actionHint}` + : actionHint; +}; + const dispatchQueryEditorSidebarLocate = (detail: Record) => { if (typeof window === 'undefined') { return; @@ -1231,6 +1467,17 @@ const clearQueryEditorLinkDecorations = ( decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, []); }; +const clearQueryEditorObjectDecorations = ( + editor: any, + decorationIdsRef: React.MutableRefObject, +) => { + if (!editor?.deltaDecorations) { + decorationIdsRef.current = []; + return; + } + decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, []); +}; + const resolveQueryLocatorPlan = async ({ statement, dbType, @@ -1416,6 +1663,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const lastExecutedEditorQueryRef = useRef(''); const linkDecorationIdsRef = useRef([]); const ctrlMetaPressedRef = useRef(false); + const objectDecorationIdsRef = useRef([]); + const objectHoverActionRef = useRef(null); + const hoverProviderDisposableRef = useRef(null); const dragRef = useRef<{ startY: number, startHeight: number } | null>(null); const queryEditorRootRef = useRef(null); const editorPaneRef = useRef(null); @@ -1517,6 +1767,119 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc connectionsRef.current = connections; }, [connections]); + const refreshObjectDecorations = useCallback(() => { + const editor = editorRef.current; + const monaco = monacoRef.current; + const model = editor?.getModel?.(); + if (!editor || !monaco || !model) { + return; + } + + const text = String(model.getValue?.() || ''); + const decorations: any[] = []; + const seen = new Set(); + const identifierRegex = /[`"\[]?[A-Za-z_][A-Za-z0-9_$]*(?:[`"\]]?\s*\.\s*[`"\[]?[A-Za-z_][A-Za-z0-9_$]*){0,2}[`"\]]?/g; + const lines = text.replace(/\r\n/g, '\n').split('\n'); + + lines.forEach((lineContent, lineIndex) => { + let match: RegExpExecArray | null; + identifierRegex.lastIndex = 0; + while ((match = identifierRegex.exec(lineContent)) !== null) { + const positionColumn = match.index + 2; + const hoverTarget = resolveQueryEditorHoverTarget( + text, + lineContent, + positionColumn, + currentDbRef.current, + visibleDbsRef.current, + tablesRef.current, + allColumnsRef.current, + viewsRef.current, + materializedViewsRef.current, + triggersRef.current, + routinesRef.current, + ); + if (!hoverTarget) continue; + + const inlineClassName = hoverTarget.kind === 'column' + ? 'gonavi-query-editor-column-token' + : hoverTarget.kind === 'database' + ? 'gonavi-query-editor-db-token' + : 'gonavi-query-editor-object-token'; + const key = `${lineIndex + 1}:${hoverTarget.range.startColumn}:${hoverTarget.range.endColumn}:${inlineClassName}`; + if (seen.has(key)) continue; + seen.add(key); + decorations.push({ + range: new monaco.Range( + lineIndex + 1, + hoverTarget.range.startColumn, + lineIndex + 1, + hoverTarget.range.endColumn, + ), + options: { inlineClassName }, + }); + } + }); + + objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, decorations); + }, []); + + const showObjectInfoAtPosition = useCallback((position?: { lineNumber: number; column: number } | null) => { + const editor = editorRef.current; + const monaco = monacoRef.current; + const model = editor?.getModel?.(); + const normalizedPosition = normalizeEditorPosition(position || editor?.getPosition?.()); + if (!editor || !model || !normalizedPosition) { + return false; + } + const lineContent = String(model.getLineContent?.(normalizedPosition.lineNumber) || ''); + const hoverTarget = resolveQueryEditorHoverTarget( + String(model.getValue?.() || ''), + lineContent, + normalizedPosition.column, + currentDbRef.current, + visibleDbsRef.current, + tablesRef.current, + allColumnsRef.current, + viewsRef.current, + materializedViewsRef.current, + triggersRef.current, + routinesRef.current, + ); + if (!hoverTarget) { + return false; + } + editor.focus?.(); + const hoverRange = monaco + ? new monaco.Range( + normalizedPosition.lineNumber, + hoverTarget.range.startColumn, + normalizedPosition.lineNumber, + hoverTarget.range.endColumn, + ) + : { + startLineNumber: normalizedPosition.lineNumber, + startColumn: hoverTarget.range.startColumn, + endLineNumber: normalizedPosition.lineNumber, + endColumn: hoverTarget.range.endColumn, + }; + const contentHoverController = editor.getContribution?.('editor.contrib.contentHover'); + if (contentHoverController?.showContentHover) { + contentHoverController.showContentHover(hoverRange, 1, 2, false); + return true; + } + editor.setPosition?.({ + lineNumber: normalizedPosition.lineNumber, + column: hoverTarget.range.startColumn, + }); + editor.trigger?.('gonavi-hover', 'editor.action.showHover', null); + return true; + }, []); + + useEffect(() => { + refreshObjectDecorations(); + }, [query, currentDb, refreshObjectDecorations]); + const getCurrentQuery = () => { const val = editorRef.current?.getValue?.(); if (typeof val === 'string') return val; @@ -1817,9 +2180,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc sharedTablesData = allTables; sharedAllColumnsData = allColumns; } + refreshObjectDecorations(); }; void fetchMetadata(); - }, [autoFetchVisible, currentConnectionId, connections, dbList, isActive]); // dbList 变化时触发重新加载 + }, [autoFetchVisible, currentConnectionId, connections, dbList, isActive, refreshObjectDecorations]); // dbList 变化时触发重新加载 // Query ID management helpers const setQueryId = (id: string) => { @@ -1859,6 +2223,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc monacoRef.current = monaco; lastEditorCursorPositionRef.current = normalizeEditorPosition(editor.getPosition?.()); + editor.updateOptions?.({ + hover: { + enabled: true, + delay: QUERY_EDITOR_HOVER_DELAY_MS, + }, + }); + const applyNavigationHoverStateAtPosition = (targetPosition: { lineNumber: number; column: number } | null) => { if (!ctrlMetaPressedRef.current) { clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); @@ -1891,6 +2262,19 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc setQueryEditorMouseCursor(editor, ''); return; } + const hoverTarget = resolveQueryEditorHoverTarget( + String(model?.getValue?.() || ''), + lineContent, + targetPosition.column, + currentDbRef.current, + visibleDbsRef.current, + tablesRef.current, + allColumnsRef.current, + viewsRef.current, + materializedViewsRef.current, + triggersRef.current, + routinesRef.current, + ); linkDecorationIdsRef.current = editor.deltaDecorations( linkDecorationIdsRef.current, @@ -1903,7 +2287,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ), options: { inlineClassName: 'gonavi-query-editor-link-hint', - hoverMessage: { value: item.hoverMessage }, + hoverMessage: { + value: buildQueryEditorNavigationHoverMarkdown(hoverTarget, item.hoverMessage), + }, }, })), ); @@ -1942,6 +2328,62 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // 应用透明主题(主题由 MonacoEditor 包装组件按需注册) monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light'); + hoverProviderDisposableRef.current?.dispose?.(); + hoverProviderDisposableRef.current = monaco.languages.registerHoverProvider('sql', { + provideHover: (model: any, position: any) => { + const normalizedPosition = normalizeEditorPosition(position); + if (!normalizedPosition) { + return null; + } + const lineContent = String(model?.getLineContent?.(normalizedPosition.lineNumber) || ''); + const hoverTarget = resolveQueryEditorHoverTarget( + String(model?.getValue?.() || ''), + lineContent, + normalizedPosition.column, + currentDbRef.current, + visibleDbsRef.current, + tablesRef.current, + allColumnsRef.current, + viewsRef.current, + materializedViewsRef.current, + triggersRef.current, + routinesRef.current, + ); + if (!hoverTarget) { + return null; + } + return { + range: new monaco.Range( + normalizedPosition.lineNumber, + hoverTarget.range.startColumn, + normalizedPosition.lineNumber, + hoverTarget.range.endColumn, + ), + contents: [{ value: buildQueryEditorHoverMarkdown(hoverTarget) }], + }; + }, + }); + + objectHoverActionRef.current?.dispose?.(); + const showObjectInfoKeybinding = monaco.KeyMod?.CtrlCmd && monaco.KeyCode?.KeyQ + ? [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyQ] + : undefined; + objectHoverActionRef.current = editor.addAction({ + id: 'gonavi.queryEditor.showObjectInfo', + label: 'GoNavi: 查看对象信息', + keybindings: showObjectInfoKeybinding, + run: () => { + const preferredPosition = lastHoverTargetPositionRef.current || editor.getPosition?.(); + const shown = showObjectInfoAtPosition(preferredPosition); + if (!shown) { + void message.info({ + key: 'gonavi-query-editor-object-info-miss', + content: '当前光标未定位到可识别的表或字段。', + }); + } + }, + }); + editor.onDidChangeCursorPosition?.((event: any) => { const position = normalizeEditorPosition(event?.position); if (position) { @@ -1949,6 +2391,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } }); + editor.onDidChangeModelContent?.(() => { + refreshObjectDecorations(); + }); + editor.onMouseMove?.((event: any) => { syncModifierState(event?.event || null); applyNavigationHoverState(event); @@ -2111,12 +2557,19 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc editor.onDidDispose?.(() => { clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); + clearQueryEditorObjectDecorations(editor, objectDecorationIdsRef); setQueryEditorMouseCursor(editor, ''); + objectHoverActionRef.current?.dispose?.(); + objectHoverActionRef.current = null; + hoverProviderDisposableRef.current?.dispose?.(); + hoverProviderDisposableRef.current = null; window.removeEventListener('keydown', syncModifierState); window.removeEventListener('keyup', syncModifierState); window.removeEventListener('blur', handleWindowBlur); }); + refreshObjectDecorations(); + // 注册 AI 右键菜单操作 const aiActions = [ { id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' }, @@ -2698,6 +3151,28 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const handleFormat = () => { try { const formatted = format(getCurrentQuery(), { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase }); + const editor = editorRef.current; + const monaco = monacoRef.current; + const model = editor?.getModel?.(); + if (editor && monaco && model) { + const currentValue = String(model.getValue?.() || ''); + if (currentValue === formatted) { + return; + } + const fullRange = model.getFullModelRange?.() + || new monaco.Range(1, 1, model.getLineCount?.() || 1, model.getLineMaxColumn?.(model.getLineCount?.() || 1) || 1); + editor.pushUndoStop?.(); + editor.executeEdits?.('gonavi-format-sql', [{ + range: fullRange, + text: formatted, + forceMoveMarkers: true, + }]); + editor.pushUndoStop?.(); + const nextValue = editor.getValue?.(); + setQuery(typeof nextValue === 'string' ? nextValue : formatted); + refreshObjectDecorations(); + return; + } syncQueryToEditor(formatted); } catch (e) { void message.error("格式化失败: SQL 语法可能有误");