feat(query-editor): 增强 SQL 编辑器对象悬浮与快捷查看能力

- 美化 SQL 改为写入 Monaco undo 栈,支持 Ctrl+Z 回退到格式化前

- 新增表名字段名库名语义着色,并在元数据加载后自动刷新装饰

- 支持鼠标悬浮和 Ctrl/Cmd+Q 查看对象信息,兼容 Ctrl/Cmd 点击跳转提示

- 补充 QueryEditor 定向测试覆盖对象 hover、快捷查看和撤销行为

Refs #506
This commit is contained in:
Syngnat
2026-05-31 15:30:09 +08:00
parent 6f132db328
commit 73f3e2cf73
3 changed files with 688 additions and 4 deletions

View File

@@ -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;
}

View File

@@ -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(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
@@ -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(<QueryEditor tab={createTab({ query: 'select * from users where id=1' })} />);
});
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(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
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<string, ((event?: any) => 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(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
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(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
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(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
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<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {

View File

@@ -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<string, { dbName: string; tableName: string }> => {
const aliasMap: Record<string, { dbName: string; tableName: string }> = {};
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<string, unknown>) => {
if (typeof window === 'undefined') {
return;
@@ -1231,6 +1467,17 @@ const clearQueryEditorLinkDecorations = (
decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, []);
};
const clearQueryEditorObjectDecorations = (
editor: any,
decorationIdsRef: React.MutableRefObject<string[]>,
) => {
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<string>('');
const linkDecorationIdsRef = useRef<string[]>([]);
const ctrlMetaPressedRef = useRef(false);
const objectDecorationIdsRef = useRef<string[]>([]);
const objectHoverActionRef = useRef<any>(null);
const hoverProviderDisposableRef = useRef<any>(null);
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
const queryEditorRootRef = useRef<HTMLDivElement | null>(null);
const editorPaneRef = useRef<HTMLDivElement | null>(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<string>();
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 语法可能有误");