mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-12 16:02:48 +08:00
✨ feat(query-editor): 增强 SQL 编辑器对象悬浮与快捷查看能力
- 美化 SQL 改为写入 Monaco undo 栈,支持 Ctrl+Z 回退到格式化前 - 新增表名字段名库名语义着色,并在元数据加载后自动刷新装饰 - 支持鼠标悬浮和 Ctrl/Cmd+Q 查看对象信息,兼容 Ctrl/Cmd 点击跳转提示 - 补充 QueryEditor 定向测试覆盖对象 hover、快捷查看和撤销行为 Refs #506
This commit is contained in:
@@ -655,3 +655,27 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
|
|||||||
text-decoration-thickness: 1px;
|
text-decoration-thickness: 1px;
|
||||||
text-underline-offset: 3px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,12 +85,14 @@ const editorState = vi.hoisted(() => {
|
|||||||
position: { lineNumber: 1, column: 1 },
|
position: { lineNumber: 1, column: 1 },
|
||||||
selection: null as any,
|
selection: null as any,
|
||||||
providers: [] as any[],
|
providers: [] as any[],
|
||||||
|
hoverProviders: [] as any[],
|
||||||
cursorPositionListeners: [] as Array<(event: any) => void>,
|
cursorPositionListeners: [] as Array<(event: any) => void>,
|
||||||
mouseMoveListeners: [] as Array<(event: any) => void>,
|
mouseMoveListeners: [] as Array<(event: any) => void>,
|
||||||
mouseDownListeners: [] as Array<(event: any) => void>,
|
mouseDownListeners: [] as Array<(event: any) => void>,
|
||||||
mouseLeaveListeners: [] as Array<() => void>,
|
mouseLeaveListeners: [] as Array<() => void>,
|
||||||
hasTextFocus: true,
|
hasTextFocus: true,
|
||||||
decorationIds: [] as string[],
|
decorationIds: [] as string[],
|
||||||
|
contentHoverCalls: [] as any[],
|
||||||
};
|
};
|
||||||
const offsetAt = (position: { lineNumber: number; column: number }) => {
|
const offsetAt = (position: { lineNumber: number; column: number }) => {
|
||||||
const text = state.value;
|
const text = state.value;
|
||||||
@@ -142,6 +144,16 @@ const editorState = vi.hoisted(() => {
|
|||||||
}),
|
}),
|
||||||
getSelection: vi.fn(() => state.selection),
|
getSelection: vi.fn(() => state.selection),
|
||||||
getDomNode: vi.fn(() => state.domNode),
|
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) => {
|
setSelection: vi.fn((selection: any) => {
|
||||||
state.selection = selection;
|
state.selection = selection;
|
||||||
}),
|
}),
|
||||||
@@ -175,6 +187,7 @@ const editorState = vi.hoisted(() => {
|
|||||||
return state.decorationIds;
|
return state.decorationIds;
|
||||||
}),
|
}),
|
||||||
updateOptions: vi.fn(),
|
updateOptions: vi.fn(),
|
||||||
|
pushUndoStop: vi.fn(),
|
||||||
onDidDispose: vi.fn(),
|
onDidDispose: vi.fn(),
|
||||||
hasTextFocus: vi.fn(() => state.hasTextFocus),
|
hasTextFocus: vi.fn(() => state.hasTextFocus),
|
||||||
revealLineInCenterIfOutsideViewport: vi.fn(),
|
revealLineInCenterIfOutsideViewport: vi.fn(),
|
||||||
@@ -205,6 +218,8 @@ vi.mock('@monaco-editor/react', () => ({
|
|||||||
editorState.value = String(defaultValue || '');
|
editorState.value = String(defaultValue || '');
|
||||||
onMount?.(editorState.editor, {
|
onMount?.(editorState.editor, {
|
||||||
editor: { setTheme: vi.fn() },
|
editor: { setTheme: vi.fn() },
|
||||||
|
KeyMod: { CtrlCmd: 2048 },
|
||||||
|
KeyCode: { KeyQ: 81 },
|
||||||
languages: {
|
languages: {
|
||||||
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
|
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
|
||||||
CompletionItemInsertTextRule: { InsertAsSnippet: 1 },
|
CompletionItemInsertTextRule: { InsertAsSnippet: 1 },
|
||||||
@@ -212,6 +227,10 @@ vi.mock('@monaco-editor/react', () => ({
|
|||||||
editorState.providers.push(provider);
|
editorState.providers.push(provider);
|
||||||
return { dispose: vi.fn() };
|
return { dispose: vi.fn() };
|
||||||
}),
|
}),
|
||||||
|
registerHoverProvider: vi.fn((_language: string, provider: any) => {
|
||||||
|
editorState.hoverProviders.push(provider);
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
Range: class {
|
Range: class {
|
||||||
startLineNumber: number;
|
startLineNumber: number;
|
||||||
@@ -352,17 +371,20 @@ describe('QueryEditor external SQL save', () => {
|
|||||||
editorState.selection = null;
|
editorState.selection = null;
|
||||||
editorState.domNode.style.cursor = '';
|
editorState.domNode.style.cursor = '';
|
||||||
editorState.providers = [];
|
editorState.providers = [];
|
||||||
|
editorState.hoverProviders = [];
|
||||||
editorState.cursorPositionListeners = [];
|
editorState.cursorPositionListeners = [];
|
||||||
editorState.mouseMoveListeners = [];
|
editorState.mouseMoveListeners = [];
|
||||||
editorState.mouseDownListeners = [];
|
editorState.mouseDownListeners = [];
|
||||||
editorState.mouseLeaveListeners = [];
|
editorState.mouseLeaveListeners = [];
|
||||||
editorState.hasTextFocus = true;
|
editorState.hasTextFocus = true;
|
||||||
editorState.decorationIds = [];
|
editorState.decorationIds = [];
|
||||||
|
editorState.contentHoverCalls = [];
|
||||||
editorState.editor.getValue.mockClear();
|
editorState.editor.getValue.mockClear();
|
||||||
editorState.editor.setValue.mockClear();
|
editorState.editor.setValue.mockClear();
|
||||||
editorState.editor.executeEdits.mockClear();
|
editorState.editor.executeEdits.mockClear();
|
||||||
editorState.editor.deltaDecorations.mockClear();
|
editorState.editor.deltaDecorations.mockClear();
|
||||||
editorState.editor.updateOptions.mockClear();
|
editorState.editor.updateOptions.mockClear();
|
||||||
|
editorState.editor.pushUndoStop.mockClear();
|
||||||
storeState.updateQueryTabDraft.mockReset();
|
storeState.updateQueryTabDraft.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -507,7 +529,7 @@ describe('QueryEditor external SQL save', () => {
|
|||||||
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] });
|
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] });
|
||||||
backendApp.DBGetAllColumns
|
backendApp.DBGetAllColumns
|
||||||
.mockResolvedValueOnce({ success: true, data: [] })
|
.mockResolvedValueOnce({ success: true, data: [] })
|
||||||
.mockResolvedValueOnce({ success: true, data: [] });
|
.mockResolvedValueOnce({ success: true, data: [{ tableName: 'events', name: 'id', type: 'bigint', comment: '事件ID' }] });
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
|
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');
|
expect(editorState.domNode.style.cursor).toBe('pointer');
|
||||||
const lastDecorationCall = editorState.editor.deltaDecorations.mock.calls.at(-1);
|
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?.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 () => {
|
await act(async () => {
|
||||||
editorState.mouseLeaveListeners[0]?.();
|
editorState.mouseLeaveListeners[0]?.();
|
||||||
@@ -539,6 +563,167 @@ describe('QueryEditor external SQL save', () => {
|
|||||||
expect(editorState.editor.updateOptions).toHaveBeenLastCalledWith({ mouseStyle: 'text' });
|
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 () => {
|
it('keeps hover underline active when ctrl/cmd is pressed repeatedly without moving the mouse', async () => {
|
||||||
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
|
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
|
||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
|
|||||||
@@ -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 Editor, { type OnMount } from './MonacoEditor';
|
||||||
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
|
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';
|
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: 'trigger'; dbName: string; triggerName: string; tableName: string; schemaName?: string }
|
||||||
| { type: 'routine'; dbName: string; routineName: string; routineType: 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_IDENTIFIER_CHAR_REGEX = /[A-Za-z0-9_$`"\[\].]/;
|
||||||
|
const QUERY_EDITOR_HOVER_DELAY_MS = 1000;
|
||||||
|
|
||||||
const findIdentifierWindowAtOffset = (
|
const findIdentifierWindowAtOffset = (
|
||||||
lineContent: string,
|
lineContent: string,
|
||||||
@@ -927,6 +937,68 @@ const normalizeNavigationIdentifierParts = (text: string): string[] => (
|
|||||||
.filter(Boolean)
|
.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 = (
|
export const resolveQueryEditorNavigationTarget = (
|
||||||
lineContent: string,
|
lineContent: string,
|
||||||
column: number,
|
column: number,
|
||||||
@@ -1147,6 +1219,160 @@ export const resolveQueryEditorNavigationTarget = (
|
|||||||
return findObjectInPriorityOrder(dbName, tableName, schemaName);
|
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 = (
|
export const resolveQueryEditorNavigationDecorations = (
|
||||||
lineContent: string,
|
lineContent: string,
|
||||||
column: number,
|
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>) => {
|
const dispatchQueryEditorSidebarLocate = (detail: Record<string, unknown>) => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -1231,6 +1467,17 @@ const clearQueryEditorLinkDecorations = (
|
|||||||
decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, []);
|
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 ({
|
const resolveQueryLocatorPlan = async ({
|
||||||
statement,
|
statement,
|
||||||
dbType,
|
dbType,
|
||||||
@@ -1416,6 +1663,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
|||||||
const lastExecutedEditorQueryRef = useRef<string>('');
|
const lastExecutedEditorQueryRef = useRef<string>('');
|
||||||
const linkDecorationIdsRef = useRef<string[]>([]);
|
const linkDecorationIdsRef = useRef<string[]>([]);
|
||||||
const ctrlMetaPressedRef = useRef(false);
|
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 dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
|
||||||
const queryEditorRootRef = useRef<HTMLDivElement | null>(null);
|
const queryEditorRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const editorPaneRef = 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;
|
connectionsRef.current = connections;
|
||||||
}, [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 getCurrentQuery = () => {
|
||||||
const val = editorRef.current?.getValue?.();
|
const val = editorRef.current?.getValue?.();
|
||||||
if (typeof val === 'string') return val;
|
if (typeof val === 'string') return val;
|
||||||
@@ -1817,9 +2180,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
|||||||
sharedTablesData = allTables;
|
sharedTablesData = allTables;
|
||||||
sharedAllColumnsData = allColumns;
|
sharedAllColumnsData = allColumns;
|
||||||
}
|
}
|
||||||
|
refreshObjectDecorations();
|
||||||
};
|
};
|
||||||
void fetchMetadata();
|
void fetchMetadata();
|
||||||
}, [autoFetchVisible, currentConnectionId, connections, dbList, isActive]); // dbList 变化时触发重新加载
|
}, [autoFetchVisible, currentConnectionId, connections, dbList, isActive, refreshObjectDecorations]); // dbList 变化时触发重新加载
|
||||||
|
|
||||||
// Query ID management helpers
|
// Query ID management helpers
|
||||||
const setQueryId = (id: string) => {
|
const setQueryId = (id: string) => {
|
||||||
@@ -1859,6 +2223,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
|||||||
monacoRef.current = monaco;
|
monacoRef.current = monaco;
|
||||||
lastEditorCursorPositionRef.current = normalizeEditorPosition(editor.getPosition?.());
|
lastEditorCursorPositionRef.current = normalizeEditorPosition(editor.getPosition?.());
|
||||||
|
|
||||||
|
editor.updateOptions?.({
|
||||||
|
hover: {
|
||||||
|
enabled: true,
|
||||||
|
delay: QUERY_EDITOR_HOVER_DELAY_MS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const applyNavigationHoverStateAtPosition = (targetPosition: { lineNumber: number; column: number } | null) => {
|
const applyNavigationHoverStateAtPosition = (targetPosition: { lineNumber: number; column: number } | null) => {
|
||||||
if (!ctrlMetaPressedRef.current) {
|
if (!ctrlMetaPressedRef.current) {
|
||||||
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
|
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
|
||||||
@@ -1891,6 +2262,19 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
|||||||
setQueryEditorMouseCursor(editor, '');
|
setQueryEditorMouseCursor(editor, '');
|
||||||
return;
|
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 = editor.deltaDecorations(
|
||||||
linkDecorationIdsRef.current,
|
linkDecorationIdsRef.current,
|
||||||
@@ -1903,7 +2287,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
|||||||
),
|
),
|
||||||
options: {
|
options: {
|
||||||
inlineClassName: 'gonavi-query-editor-link-hint',
|
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 包装组件按需注册)
|
// 应用透明主题(主题由 MonacoEditor 包装组件按需注册)
|
||||||
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
|
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) => {
|
editor.onDidChangeCursorPosition?.((event: any) => {
|
||||||
const position = normalizeEditorPosition(event?.position);
|
const position = normalizeEditorPosition(event?.position);
|
||||||
if (position) {
|
if (position) {
|
||||||
@@ -1949,6 +2391,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
editor.onDidChangeModelContent?.(() => {
|
||||||
|
refreshObjectDecorations();
|
||||||
|
});
|
||||||
|
|
||||||
editor.onMouseMove?.((event: any) => {
|
editor.onMouseMove?.((event: any) => {
|
||||||
syncModifierState(event?.event || null);
|
syncModifierState(event?.event || null);
|
||||||
applyNavigationHoverState(event);
|
applyNavigationHoverState(event);
|
||||||
@@ -2111,12 +2557,19 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
|||||||
|
|
||||||
editor.onDidDispose?.(() => {
|
editor.onDidDispose?.(() => {
|
||||||
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
|
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
|
||||||
|
clearQueryEditorObjectDecorations(editor, objectDecorationIdsRef);
|
||||||
setQueryEditorMouseCursor(editor, '');
|
setQueryEditorMouseCursor(editor, '');
|
||||||
|
objectHoverActionRef.current?.dispose?.();
|
||||||
|
objectHoverActionRef.current = null;
|
||||||
|
hoverProviderDisposableRef.current?.dispose?.();
|
||||||
|
hoverProviderDisposableRef.current = null;
|
||||||
window.removeEventListener('keydown', syncModifierState);
|
window.removeEventListener('keydown', syncModifierState);
|
||||||
window.removeEventListener('keyup', syncModifierState);
|
window.removeEventListener('keyup', syncModifierState);
|
||||||
window.removeEventListener('blur', handleWindowBlur);
|
window.removeEventListener('blur', handleWindowBlur);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
refreshObjectDecorations();
|
||||||
|
|
||||||
// 注册 AI 右键菜单操作
|
// 注册 AI 右键菜单操作
|
||||||
const aiActions = [
|
const aiActions = [
|
||||||
{ id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' },
|
{ id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' },
|
||||||
@@ -2698,6 +3151,28 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
|||||||
const handleFormat = () => {
|
const handleFormat = () => {
|
||||||
try {
|
try {
|
||||||
const formatted = format(getCurrentQuery(), { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase });
|
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);
|
syncQueryToEditor(formatted);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
void message.error("格式化失败: SQL 语法可能有误");
|
void message.error("格式化失败: SQL 语法可能有误");
|
||||||
|
|||||||
Reference in New Issue
Block a user