mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-12 17:39:42 +08:00
🐛 fix(query-editor): 优化 SQL 补全和结果页交互
- 修复新建查询页输入后表名补全失效,支持当前库懒加载与模糊匹配 - 限制长 SQL 实时装饰和持久化草稿,降低输入卡顿 - 执行相同格式化 SQL 时复用结果页并聚焦对应结果标签 - 查询结果标签增加右键关闭菜单并优化标签样式和选中文字行为
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SavedQuery, TabData } from '../types';
|
||||
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
|
||||
import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
|
||||
import QueryEditor, { resolveQueryEditorNavigationTarget } from './QueryEditor';
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
@@ -92,12 +94,14 @@ const editorState = vi.hoisted(() => {
|
||||
providers: [] as any[],
|
||||
hoverProviders: [] as any[],
|
||||
cursorPositionListeners: [] as Array<(event: any) => void>,
|
||||
modelContentListeners: [] 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[],
|
||||
latestOnChange: null as null | ((value?: string) => void),
|
||||
};
|
||||
const offsetAt = (position: { lineNumber: number; column: number }) => {
|
||||
const text = state.value;
|
||||
@@ -125,7 +129,8 @@ const editorState = vi.hoisted(() => {
|
||||
return state.value.slice(Math.min(start, end), Math.max(start, end));
|
||||
};
|
||||
const model = {
|
||||
getValue: () => state.value,
|
||||
getValue: vi.fn(() => state.value),
|
||||
getValueLength: vi.fn(() => state.value.length),
|
||||
setValue: (value: string) => {
|
||||
state.value = value;
|
||||
},
|
||||
@@ -133,7 +138,16 @@ const editorState = vi.hoisted(() => {
|
||||
getLineContent: (lineNumber: number) => state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '',
|
||||
getLineCount: () => state.value.replace(/\r\n/g, '\n').split('\n').length,
|
||||
getLineMaxColumn: (lineNumber: number) => (state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '').length + 1,
|
||||
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1, word: '' }),
|
||||
getWordUntilPosition: (position: { lineNumber: number; column: number }) => {
|
||||
const lineContent = model.getLineContent(position.lineNumber);
|
||||
const beforeCursor = lineContent.slice(0, Math.max(0, position.column - 1));
|
||||
const word = beforeCursor.match(/[A-Za-z0-9_$]*$/)?.[0] || '';
|
||||
return {
|
||||
startColumn: position.column - word.length,
|
||||
endColumn: position.column,
|
||||
word,
|
||||
};
|
||||
},
|
||||
getOffsetAt: offsetAt,
|
||||
getPositionAt: positionAt,
|
||||
};
|
||||
@@ -170,7 +184,10 @@ const editorState = vi.hoisted(() => {
|
||||
});
|
||||
}),
|
||||
addAction: vi.fn(),
|
||||
onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
onDidChangeModelContent: vi.fn((listener: (event: any) => void) => {
|
||||
state.modelContentListeners.push(listener);
|
||||
return { dispose: vi.fn() };
|
||||
}),
|
||||
onDidChangeCursorPosition: vi.fn((listener: (event: any) => void) => {
|
||||
state.cursorPositionListeners.push(listener);
|
||||
return { dispose: vi.fn() };
|
||||
@@ -197,6 +214,7 @@ const editorState = vi.hoisted(() => {
|
||||
hasTextFocus: vi.fn(() => state.hasTextFocus),
|
||||
revealLineInCenterIfOutsideViewport: vi.fn(),
|
||||
revealRangeInCenterIfOutsideViewport: vi.fn(),
|
||||
layout: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
trigger: vi.fn(),
|
||||
};
|
||||
@@ -218,9 +236,10 @@ vi.mock('../utils/autoFetchVisibility', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: ({ defaultValue, onMount }: any) => {
|
||||
default: ({ defaultValue, onChange, onMount }: any) => {
|
||||
React.useEffect(() => {
|
||||
editorState.value = String(defaultValue || '');
|
||||
editorState.latestOnChange = onChange;
|
||||
onMount?.(editorState.editor, {
|
||||
editor: { setTheme: vi.fn() },
|
||||
KeyMod: { CtrlCmd: 2048, WinCtrl: 256 },
|
||||
@@ -361,10 +380,25 @@ const createTab = (overrides: Partial<TabData> = {}): TabData => ({
|
||||
|
||||
describe('QueryEditor external SQL save', () => {
|
||||
beforeEach(() => {
|
||||
const completionState = (globalThis as any).__gonaviSqlCompletionState;
|
||||
if (completionState) {
|
||||
completionState.registered = false;
|
||||
completionState.disposables = [];
|
||||
}
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}),
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
innerHeight: 900,
|
||||
});
|
||||
vi.stubGlobal('document', {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
storeState.addTab.mockReset();
|
||||
storeState.setActiveContext.mockReset();
|
||||
@@ -396,19 +430,26 @@ describe('QueryEditor external SQL save', () => {
|
||||
editorState.providers = [];
|
||||
editorState.hoverProviders = [];
|
||||
editorState.cursorPositionListeners = [];
|
||||
editorState.modelContentListeners = [];
|
||||
editorState.mouseMoveListeners = [];
|
||||
editorState.mouseDownListeners = [];
|
||||
editorState.mouseLeaveListeners = [];
|
||||
editorState.hasTextFocus = true;
|
||||
editorState.decorationIds = [];
|
||||
editorState.contentHoverCalls = [];
|
||||
editorState.latestOnChange = null;
|
||||
editorState.editor.getValue.mockClear();
|
||||
editorState.editor.getModel().getValue.mockClear();
|
||||
editorState.editor.getModel().getValueLength.mockClear();
|
||||
editorState.editor.setValue.mockClear();
|
||||
editorState.editor.executeEdits.mockClear();
|
||||
editorState.editor.deltaDecorations.mockClear();
|
||||
editorState.editor.updateOptions.mockClear();
|
||||
editorState.editor.pushUndoStop.mockClear();
|
||||
editorState.editor.layout.mockClear();
|
||||
storeState.updateQueryTabDraft.mockReset();
|
||||
clearQueryTabDraft('tab-1');
|
||||
clearSQLFileTabDraft('tab-1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -424,6 +465,105 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(editorState.value).toBe('SELECT * FROM ');
|
||||
});
|
||||
|
||||
it('keeps table name completion available after typing in a fresh query tab', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
autoFetchState.visible = true;
|
||||
storeState.connections[0].config.database = '';
|
||||
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'information_schema' }, { Database: 'main' }] });
|
||||
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [] });
|
||||
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
|
||||
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'organization' }] });
|
||||
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ query: '' })} />);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.'));
|
||||
expect(sqlProvider).toBeTruthy();
|
||||
expect(storeState.updateQueryTabDraft).toHaveBeenLastCalledWith('tab-1', expect.objectContaining({
|
||||
dbName: 'main',
|
||||
}));
|
||||
|
||||
editorState.value = 'SELECT * FROM org';
|
||||
editorState.latestOnChange?.(editorState.value);
|
||||
const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: editorState.value.length + 1 });
|
||||
|
||||
expect(result.suggestions.map((item: any) => item.label)).toContain('organization');
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('fuzzy matches table names in FROM completion before column candidates', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
autoFetchState.visible = true;
|
||||
storeState.connections[0].config.database = '';
|
||||
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'information_schema' }, { Database: 'main' }] });
|
||||
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [] });
|
||||
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
|
||||
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'fs_org_auth_application' }] });
|
||||
backendApp.DBGetAllColumns.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ tableName: 'fs_org_auth_application', name: 'orgi', type: 'varchar(32)' }],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ query: '' })} />);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.'));
|
||||
expect(sqlProvider).toBeTruthy();
|
||||
|
||||
editorState.value = 'SELECT * FROM org';
|
||||
editorState.latestOnChange?.(editorState.value);
|
||||
const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: editorState.value.length + 1 });
|
||||
const labels = result.suggestions.map((item: any) => item.label);
|
||||
|
||||
expect(labels).toContain('fs_org_auth_application');
|
||||
expect(labels).not.toContain('orgi');
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('lazy loads current database tables for FROM completion when metadata is not preloaded', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
autoFetchState.visible = false;
|
||||
backendApp.DBGetTables.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ Table: 'fs_org_auth_application' }],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ query: '', dbName: 'front_end_sys' })} />);
|
||||
});
|
||||
|
||||
const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.'));
|
||||
expect(sqlProvider).toBeTruthy();
|
||||
|
||||
editorState.value = 'SELECT * FROM or';
|
||||
editorState.latestOnChange?.(editorState.value);
|
||||
const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: editorState.value.length + 1 });
|
||||
const labels = result.suggestions.map((item: any) => item.label);
|
||||
|
||||
expect(backendApp.DBGetTables).toHaveBeenCalledWith(expect.any(Object), 'front_end_sys');
|
||||
expect(labels).toContain('fs_org_auth_application');
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves database and table targets for ctrl/cmd navigation', () => {
|
||||
const tables = [
|
||||
{ dbName: 'main', tableName: 'users' },
|
||||
@@ -576,7 +716,7 @@ 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 + 点击打开该表');
|
||||
expect(lastDecorationCall?.[1]?.[0]?.options?.hoverMessage?.value).toMatch(/(?:Ctrl|⌘) \+ 点击打开该表/);
|
||||
expect(lastDecorationCall?.[1]?.[0]?.options?.hoverMessage?.value).toContain('**表** `events`');
|
||||
|
||||
await act(async () => {
|
||||
@@ -645,6 +785,31 @@ describe('QueryEditor external SQL save', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('renders SQL metadata hover as a fixed overflow widget below first-line tokens', 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' })} />);
|
||||
});
|
||||
|
||||
const initialOptions = editorState.editor.updateOptions.mock.calls[0]?.[0];
|
||||
expect(initialOptions).toMatchObject({
|
||||
fixedOverflowWidgets: true,
|
||||
hover: {
|
||||
enabled: true,
|
||||
delay: 1000,
|
||||
above: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the hovered identifier position for ctrl+q object info', async () => {
|
||||
editorState.value = 'select * from user_actions';
|
||||
autoFetchState.visible = true;
|
||||
@@ -1008,6 +1173,103 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已保存!');
|
||||
});
|
||||
|
||||
it('keeps external SQL file typing out of persisted tab drafts to avoid input freezes', async () => {
|
||||
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
|
||||
|
||||
await act(async () => {
|
||||
create(<QueryEditor tab={createTab({ filePath })} />);
|
||||
});
|
||||
|
||||
storeState.updateQueryTabDraft.mockClear();
|
||||
editorState.editor.deltaDecorations.mockClear();
|
||||
editorState.editor.getModel().getValue.mockClear();
|
||||
editorState.editor.getModel().getValueLength.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
editorState.value = 'select 1;\n1';
|
||||
editorState.latestOnChange?.(editorState.value);
|
||||
editorState.modelContentListeners.forEach((listener) => listener({
|
||||
changes: [{ text: '1' }],
|
||||
}));
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.updateQueryTabDraft).not.toHaveBeenCalledWith('tab-1', expect.objectContaining({
|
||||
query: 'select 1;\n1',
|
||||
}));
|
||||
expect(getSQLFileTabDraft('tab-1')).toBe('select 1;\n1');
|
||||
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
|
||||
expect(editorState.editor.getModel().getValue).not.toHaveBeenCalled();
|
||||
expect(editorState.editor.getModel().getValueLength).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps large regular query typing out of persisted tab drafts to avoid input freezes', async () => {
|
||||
const largeSql = `select * from users;\n${'x'.repeat(60_000)}`;
|
||||
|
||||
await act(async () => {
|
||||
create(<QueryEditor tab={createTab({ query: 'select 1;' })} />);
|
||||
});
|
||||
|
||||
storeState.updateQueryTabDraft.mockClear();
|
||||
editorState.editor.deltaDecorations.mockClear();
|
||||
editorState.editor.getModel().getValue.mockClear();
|
||||
editorState.editor.getModel().getValueLength.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
editorState.value = largeSql;
|
||||
editorState.latestOnChange?.(largeSql);
|
||||
editorState.modelContentListeners.forEach((listener) => listener({
|
||||
changes: [{ text: largeSql }],
|
||||
}));
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.updateQueryTabDraft).not.toHaveBeenCalledWith('tab-1', expect.objectContaining({
|
||||
query: largeSql,
|
||||
}));
|
||||
expect(getQueryTabDraft('tab-1')).toBe(largeSql);
|
||||
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
|
||||
expect(editorState.editor.getModel().getValueLength).not.toHaveBeenCalled();
|
||||
expect(editorState.editor.getModel().getValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps short regular query typing on the Monaco fast path without rerender side effects', async () => {
|
||||
await act(async () => {
|
||||
create(<QueryEditor tab={createTab({ query: 'select 1;' })} />);
|
||||
});
|
||||
|
||||
storeState.updateQueryTabDraft.mockClear();
|
||||
editorState.editor.deltaDecorations.mockClear();
|
||||
editorState.editor.getModel().getValue.mockClear();
|
||||
editorState.editor.getModel().getValueLength.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
editorState.value = 'SELECT * FROM fs_org_auth_application;\n\nSELECT * FROM fs_bcp_auth_info; ';
|
||||
editorState.latestOnChange?.(editorState.value);
|
||||
editorState.modelContentListeners.forEach((listener) => listener({
|
||||
changes: [{ text: ' ' }],
|
||||
}));
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getQueryTabDraft('tab-1')).toBe('SELECT * FROM fs_org_auth_application;\n\nSELECT * FROM fs_bcp_auth_info; ');
|
||||
expect(storeState.updateQueryTabDraft).not.toHaveBeenCalledWith('tab-1', expect.objectContaining({
|
||||
query: expect.any(String),
|
||||
}));
|
||||
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
|
||||
expect(editorState.editor.getModel().getValue).not.toHaveBeenCalled();
|
||||
expect(editorState.editor.getModel().getValueLength).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('registers Ctrl/Cmd+S to quick-save the active query', async () => {
|
||||
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
|
||||
vi.stubGlobal('window', {
|
||||
@@ -1039,13 +1301,14 @@ describe('QueryEditor external SQL save', () => {
|
||||
.find((action: any) => action?.id === 'gonavi.saveQuery');
|
||||
expect(saveAction).toMatchObject({
|
||||
label: 'GoNavi: 保存查询',
|
||||
keybindings: [2048 | 83],
|
||||
});
|
||||
expect(saveAction?.keybindings?.[0]).toBeGreaterThan(0);
|
||||
|
||||
editorState.value = 'select 5;';
|
||||
const isMacRuntime = /(Mac|iPhone|iPad|iPod)/i.test(`${navigator.platform || ''} ${navigator.userAgent || ''}`);
|
||||
const event = {
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
ctrlKey: !isMacRuntime,
|
||||
metaKey: isMacRuntime,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
key: 's',
|
||||
@@ -1645,6 +1908,11 @@ describe('QueryEditor external SQL save', () => {
|
||||
});
|
||||
|
||||
it('does not execute SQL when the cursor is on a blank line', async () => {
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({
|
||||
@@ -1653,6 +1921,30 @@ describe('QueryEditor external SQL save', () => {
|
||||
})} />);
|
||||
});
|
||||
|
||||
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
|
||||
editorState.selection = {
|
||||
startLineNumber: 1,
|
||||
startColumn: 'select 1 as a;'.length + 1,
|
||||
endLineNumber: 1,
|
||||
endColumn: 'select 1 as a;'.length + 1,
|
||||
positionLineNumber: 1,
|
||||
positionColumn: 'select 1 as a;'.length + 1,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
const runButton = findButton(renderer!, '运行');
|
||||
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
|
||||
await runButton.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(textContent(renderer!.toJSON())).toContain('结果 1');
|
||||
backendApp.DBQueryMulti.mockClear();
|
||||
messageApi.info.mockClear();
|
||||
|
||||
editorState.position = { lineNumber: 3, column: 1 };
|
||||
editorState.selection = {
|
||||
startLineNumber: 3,
|
||||
@@ -1678,6 +1970,8 @@ describe('QueryEditor external SQL save', () => {
|
||||
|
||||
expect(backendApp.DBQueryMulti).not.toHaveBeenCalled();
|
||||
expect(messageApi.info).toHaveBeenCalledWith('没有可执行的 SQL。');
|
||||
expect(textContent(renderer!.toJSON())).toContain('结果 1');
|
||||
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })]));
|
||||
});
|
||||
|
||||
it('runs only appended SQL and keeps existing results after a full editor execution', async () => {
|
||||
@@ -1729,8 +2023,149 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b');
|
||||
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a');
|
||||
expect(textContent(renderer!.toJSON())).toContain('结果 1');
|
||||
expect(textContent(renderer!.toJSON())).toContain('(1)');
|
||||
expect(textContent(renderer!.toJSON())).toContain('结果 2');
|
||||
expect(renderer!.root.findAll((node) => {
|
||||
const className = String(node.props?.className || '');
|
||||
return className.includes('query-result-tab-count') && textContent(node) === '1';
|
||||
})).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('replaces existing result tabs when rerunning the same formatted SQL', async () => {
|
||||
backendApp.DBQueryMulti
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [
|
||||
{ columns: ['id'], rows: [{ id: 1 }, { id: 2 }, { id: 3 }] },
|
||||
{ columns: ['id'], rows: Array.from({ length: 10 }, (_, index) => ({ id: index + 1 })) },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [
|
||||
{ columns: ['id'], rows: [{ id: 11 }, { id: 12 }, { id: 13 }] },
|
||||
{ columns: ['id'], rows: Array.from({ length: 10 }, (_, index) => ({ id: index + 11 })) },
|
||||
],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({
|
||||
dbName: 'main',
|
||||
query: 'SELECT * FROM fs_org_auth_application;\nSELECT * FROM fs_bcp_auth_info;',
|
||||
})} />);
|
||||
});
|
||||
|
||||
editorState.position = { lineNumber: 1, column: 'SELECT * FROM fs_org_auth_application;'.length + 1 };
|
||||
editorState.selection = null;
|
||||
|
||||
await act(async () => {
|
||||
const runButton = findButton(renderer!, '运行');
|
||||
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
|
||||
await runButton.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(textContent(renderer!.toJSON())).toContain('结果 1');
|
||||
expect(textContent(renderer!.toJSON())).toContain('结果 2');
|
||||
|
||||
editorState.value = [
|
||||
'SELECT',
|
||||
' *',
|
||||
'FROM',
|
||||
' fs_org_auth_application;',
|
||||
'',
|
||||
'SELECT',
|
||||
' *',
|
||||
'FROM',
|
||||
' fs_bcp_auth_info;',
|
||||
].join('\n');
|
||||
editorState.position = { lineNumber: 4, column: ' fs_org_auth_application;'.length + 1 };
|
||||
editorState.selection = null;
|
||||
|
||||
await act(async () => {
|
||||
const runButton = findButton(renderer!, '运行');
|
||||
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
|
||||
await runButton.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
|
||||
expect(textContent(renderer!.toJSON())).toContain('结果 1');
|
||||
expect(textContent(renderer!.toJSON())).toContain('结果 2');
|
||||
expect(textContent(renderer!.toJSON())).not.toContain('结果 3');
|
||||
expect(textContent(renderer!.toJSON())).not.toContain('结果 4');
|
||||
expect(renderer!.root.findAll((node) => {
|
||||
const className = String(node.props?.className || '');
|
||||
return className.includes('query-result-tab-label');
|
||||
})).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('provides context menu actions for query result tabs', async () => {
|
||||
backendApp.DBQueryMulti.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{ columns: ['a'], rows: [{ a: 1 }] },
|
||||
{ columns: ['b'], rows: [{ b: 2 }] },
|
||||
{ columns: ['c'], rows: [{ c: 3 }] },
|
||||
],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({
|
||||
dbName: 'main',
|
||||
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
|
||||
})} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const runButton = findButton(renderer!, '运行');
|
||||
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
|
||||
await runButton.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(renderer!.root.findAll((node) => {
|
||||
const className = String(node.props?.className || '');
|
||||
return className.includes('query-result-tab-label');
|
||||
})).toHaveLength(3);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭右侧')[1].props.onClick();
|
||||
});
|
||||
expect(renderer!.root.findAll((node) => {
|
||||
const className = String(node.props?.className || '');
|
||||
return className.includes('query-result-tab-label');
|
||||
})).toHaveLength(2);
|
||||
expect(textContent(renderer!.toJSON())).not.toContain('结果 3');
|
||||
|
||||
await act(async () => {
|
||||
renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭左侧')[1].props.onClick();
|
||||
});
|
||||
expect(renderer!.root.findAll((node) => {
|
||||
const className = String(node.props?.className || '');
|
||||
return className.includes('query-result-tab-label');
|
||||
})).toHaveLength(1);
|
||||
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })]));
|
||||
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })]));
|
||||
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ c: 3 })]));
|
||||
|
||||
await act(async () => {
|
||||
renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭所有')[0].props.onClick();
|
||||
});
|
||||
expect(renderer!.root.findAll((node) => {
|
||||
const className = String(node.props?.className || '');
|
||||
return className.includes('query-result-tab-label');
|
||||
})).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('replaces the current result when rerunning the same cursor SQL', async () => {
|
||||
@@ -1857,6 +2292,112 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 3 as c');
|
||||
expect(textContent(renderer!.toJSON())).toContain('结果 1');
|
||||
expect(textContent(renderer!.toJSON())).toContain('结果 2');
|
||||
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })]));
|
||||
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })]));
|
||||
});
|
||||
|
||||
it('renders compact result tab labels with row counts outside the title text', async () => {
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [
|
||||
{ columns: ['a'], rows: [{ a: 1 }, { a: 2 }] },
|
||||
{ columns: ['b'], rows: [{ b: 3 }] },
|
||||
],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({
|
||||
dbName: 'main',
|
||||
query: 'select 1 as a;\nselect 2 as b;',
|
||||
})} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const tabLabels = renderer!.root.findAll((node) => {
|
||||
const className = String(node.props?.className || '');
|
||||
return className.includes('query-result-tab-label');
|
||||
});
|
||||
const counts = renderer!.root.findAll((node) => {
|
||||
const className = String(node.props?.className || '');
|
||||
return className.includes('query-result-tab-count');
|
||||
});
|
||||
const titles = renderer!.root.findAll((node) => {
|
||||
const className = String(node.props?.className || '');
|
||||
return className.includes('query-result-tab-text');
|
||||
});
|
||||
|
||||
expect(tabLabels).toHaveLength(2);
|
||||
expect(titles.map((node) => textContent(node))).toEqual(['结果 1', '结果 2']);
|
||||
expect(counts.map((node) => textContent(node))).toEqual(['2', '1']);
|
||||
expect(textContent(renderer!.toJSON())).not.toContain('结果 1 (2)');
|
||||
});
|
||||
|
||||
it('keeps query result tabs compact, centered, and readable in v2 UI', () => {
|
||||
const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8');
|
||||
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('.query-result-tabs .ant-tabs-tab {');
|
||||
expect(source).toContain('width: auto !important;');
|
||||
expect(source).toContain('max-width: 148px !important;');
|
||||
expect(source).toContain('height: 30px !important;');
|
||||
expect(source).toContain('align-items: center !important;');
|
||||
expect(source).toContain('font-size: 14px !important;');
|
||||
expect(source).toContain('.query-result-tab-text {');
|
||||
expect(source).toContain('user-select: none;');
|
||||
expect(source).toContain('font-weight: 700;');
|
||||
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab {');
|
||||
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab-btn {');
|
||||
expect(css).toContain('user-select: none;');
|
||||
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tab-text {');
|
||||
});
|
||||
|
||||
it('coalesces editor result splitter dragging through requestAnimationFrame', async () => {
|
||||
const moveListeners: Array<(event: MouseEvent) => void> = [];
|
||||
const upListeners: Array<() => void> = [];
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
vi.mocked(document.addEventListener).mockImplementation((type: string, listener: any) => {
|
||||
if (type === 'mousemove') moveListeners.push(listener);
|
||||
if (type === 'mouseup') upListeners.push(listener);
|
||||
});
|
||||
vi.mocked(window.requestAnimationFrame).mockImplementation((callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
});
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab()} />);
|
||||
});
|
||||
|
||||
const resizer = renderer.root.find((node) => node.props?.title === '拖动调整高度');
|
||||
await act(async () => {
|
||||
resizer.props.onMouseDown({ clientY: 300, preventDefault: vi.fn() });
|
||||
moveListeners.forEach((listener) => listener({ clientY: 340 } as MouseEvent));
|
||||
moveListeners.forEach((listener) => listener({ clientY: 380 } as MouseEvent));
|
||||
});
|
||||
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
expect(editorState.editor.layout).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
frameCallbacks.splice(0).forEach((callback) => callback(16));
|
||||
});
|
||||
expect(editorState.editor.layout).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
upListeners.forEach((listener) => listener());
|
||||
});
|
||||
expect(editorState.editor.layout).toHaveBeenCalledTimes(2);
|
||||
expect(document.removeEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function));
|
||||
expect(document.removeEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function));
|
||||
});
|
||||
|
||||
it('runs selected SQL before cursor SQL', async () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { splitSidebarQualifiedName } from '../utils/sidebarLocate';
|
||||
import { normalizeSidebarViewName } from '../utils/sidebarMetadata';
|
||||
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
|
||||
import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator';
|
||||
import { getQueryTabDraft, hasQueryTabDraft, setQueryTabDraft, setSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
|
||||
import {
|
||||
getColumnDefinitionKey,
|
||||
getColumnDefinitionName,
|
||||
@@ -188,8 +189,12 @@ const SQL_FUNCTIONS: { name: string; detail: string }[] = [
|
||||
|
||||
// HMR 重载时释放旧注册避免补全项重复
|
||||
const _g = globalThis as any;
|
||||
const SQL_COMPLETION_PROVIDER_VERSION = '20260602-table-fuzzy-lazy-v2';
|
||||
if (!_g.__gonaviSqlCompletionState) {
|
||||
_g.__gonaviSqlCompletionState = { registered: false, disposables: [] as any[] };
|
||||
_g.__gonaviSqlCompletionState = { registered: false, version: '', disposables: [] as any[] };
|
||||
}
|
||||
if (!Array.isArray(_g.__gonaviSqlCompletionState.disposables)) {
|
||||
_g.__gonaviSqlCompletionState.disposables = [];
|
||||
}
|
||||
let sqlCompletionRegistered = _g.__gonaviSqlCompletionState.registered;
|
||||
let sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables;
|
||||
@@ -208,6 +213,8 @@ let sharedTablesData: CompletionTableMeta[] = [];
|
||||
let sharedAllColumnsData: CompletionColumnMeta[] = [];
|
||||
let sharedVisibleDbs: string[] = [];
|
||||
let sharedColumnsCacheData: Record<string, any[]> = {};
|
||||
const sharedLazyTablesCache: Record<string, CompletionTableMeta[] | undefined> = {};
|
||||
const sharedLazyTablesInFlight: Record<string, Promise<CompletionTableMeta[]> | undefined> = {};
|
||||
|
||||
const QUERY_LOCATOR_ALIAS_PREFIX = '__gonavi_locator_';
|
||||
|
||||
@@ -609,6 +616,9 @@ const getTabQueryValue = (tab: TabData): string => (
|
||||
);
|
||||
|
||||
const getInitialEditorQuery = (tab: TabData): string => {
|
||||
if (hasQueryTabDraft(tab.id)) {
|
||||
return getQueryTabDraft(tab.id);
|
||||
}
|
||||
const tabQuery = getTabQueryValue(tab);
|
||||
if (tabQuery || tab.filePath || tab.savedQueryId || tab.readOnly) {
|
||||
return tabQuery;
|
||||
@@ -625,16 +635,19 @@ const resolveNextResultSetIndex = (sets: Array<{ key?: string }>): number => {
|
||||
return maxIndex + 1;
|
||||
};
|
||||
|
||||
const areSqlStatementListsEqual = (left: string[], right: string[]): boolean => (
|
||||
left.length === right.length && left.every((statement, index) => statement === right[index])
|
||||
);
|
||||
|
||||
const normalizeExecutedSqlKey = (sql: string): string => String(sql || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/;/g, ';')
|
||||
.trim()
|
||||
.replace(/;+\s*$/g, '')
|
||||
.trim();
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
|
||||
const areSqlStatementListsEqual = (left: string[], right: string[]): boolean => (
|
||||
left.length === right.length
|
||||
&& left.every((statement, index) => normalizeExecutedSqlKey(statement) === normalizeExecutedSqlKey(right[index]))
|
||||
);
|
||||
|
||||
const normalizeEditorPosition = (position: any): { lineNumber: number; column: number } | null => {
|
||||
if (!position) return null;
|
||||
@@ -899,6 +912,37 @@ type QueryEditorHoverTarget =
|
||||
|
||||
const QUERY_EDITOR_IDENTIFIER_CHAR_REGEX = /[A-Za-z0-9_$`"\[\].]/;
|
||||
const QUERY_EDITOR_HOVER_DELAY_MS = 1000;
|
||||
const QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH = 200_000;
|
||||
const QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS = 800;
|
||||
const QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH = 50_000;
|
||||
const QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH = 50_000;
|
||||
|
||||
const getQueryEditorModelValueLength = (model: any): number | null => {
|
||||
if (!model || typeof model.getValueLength !== 'function') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const length = Number(model.getValueLength());
|
||||
return Number.isFinite(length) ? length : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getQueryEditorModelTextIfWithinLimit = (model: any, maxTextLength: number): string | null => {
|
||||
const modelLength = getQueryEditorModelValueLength(model);
|
||||
if (modelLength !== null && modelLength > maxTextLength) {
|
||||
return null;
|
||||
}
|
||||
const text = String(model?.getValue?.() || '');
|
||||
return text.length <= maxTextLength ? text : null;
|
||||
};
|
||||
|
||||
const getQueryEditorObjectResolveText = (
|
||||
model: any,
|
||||
lineContent: string,
|
||||
maxTextLength = QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH,
|
||||
): string => getQueryEditorModelTextIfWithinLimit(model, maxTextLength) ?? lineContent;
|
||||
|
||||
const findIdentifierWindowAtOffset = (
|
||||
lineContent: string,
|
||||
@@ -1629,6 +1673,7 @@ const resolveQueryLocatorPlan = async ({
|
||||
|
||||
const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
|
||||
const [query, setQuery] = useState(getInitialEditorQuery(tab));
|
||||
const isExternalSQLFileTab = Boolean(String(tab.filePath || '').trim());
|
||||
|
||||
type ResultSet = {
|
||||
key: string;
|
||||
@@ -1663,6 +1708,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
// Resizing state
|
||||
const [editorHeight, setEditorHeight] = useState(300);
|
||||
const editorShellRef = useRef<HTMLDivElement | null>(null);
|
||||
const editorRef = useRef<any>(null);
|
||||
const monacoRef = useRef<any>(null);
|
||||
const runQueryActionRef = useRef<any>(null);
|
||||
@@ -1677,7 +1723,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
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, currentHeight: number } | null>(null);
|
||||
const pendingEditorHeightRef = useRef(editorHeight);
|
||||
const resizeFrameRef = useRef<number | null>(null);
|
||||
const queryEditorRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const editorPaneRef = useRef<HTMLDivElement | null>(null);
|
||||
const tablesRef = useRef<CompletionTableMeta[]>([]); // Store tables for autocomplete (cross-db)
|
||||
@@ -1743,6 +1791,27 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return savedQueries.find((item) => item.id === tabId) || null;
|
||||
}, [savedQueries, tab.id, tab.savedQueryId]);
|
||||
|
||||
const syncQueryDraft = useCallback((nextQuery: string) => {
|
||||
const next = String(nextQuery ?? '');
|
||||
if (isExternalSQLFileTab) {
|
||||
setSQLFileTabDraft(tab.id, next);
|
||||
return;
|
||||
}
|
||||
setQueryTabDraft(tab.id, next);
|
||||
}, [isExternalSQLFileTab, tab.id]);
|
||||
|
||||
const applyQueryState = useCallback((nextQuery: string) => {
|
||||
const next = String(nextQuery ?? '');
|
||||
syncQueryDraft(next);
|
||||
if (!isExternalSQLFileTab || next.length <= QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH) {
|
||||
setQuery(next);
|
||||
}
|
||||
}, [isExternalSQLFileTab, syncQueryDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryTabDraft(tab.id, query);
|
||||
}, [query, tab.id]);
|
||||
|
||||
useEffect(() => {
|
||||
currentConnectionIdRef.current = currentConnectionId;
|
||||
}, [currentConnectionId]);
|
||||
@@ -1762,12 +1831,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}, [currentDb]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExternalSQLFileTab) return;
|
||||
const currentDraft = getQueryTabDraft(tab.id, query);
|
||||
const shouldPersistQuery = currentDraft.length <= QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH;
|
||||
updateQueryTabDraft(tab.id, {
|
||||
query,
|
||||
...(shouldPersistQuery ? { query: currentDraft } : {}),
|
||||
connectionId: currentConnectionId,
|
||||
dbName: currentDb,
|
||||
});
|
||||
}, [currentConnectionId, currentDb, query, tab.id, updateQueryTabDraft]);
|
||||
}, [currentConnectionId, currentDb, isExternalSQLFileTab, query, tab.id, updateQueryTabDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExternalSQLFileTab) return;
|
||||
updateQueryTabDraft(tab.id, {
|
||||
connectionId: currentConnectionId,
|
||||
dbName: currentDb,
|
||||
});
|
||||
}, [currentConnectionId, currentDb, isExternalSQLFileTab, tab.id, updateQueryTabDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExternalSQLFileTab) return;
|
||||
setSQLFileTabDraft(tab.id, getCurrentQuery());
|
||||
return () => {
|
||||
setSQLFileTabDraft(tab.id, getCurrentQuery());
|
||||
};
|
||||
}, [isExternalSQLFileTab, tab.id]);
|
||||
|
||||
// 当此 Tab 成为活跃 Tab 时,将本实例的状态同步到模块级共享变量
|
||||
// 确保 completion provider 始终使用当前活跃 Tab 的上下文
|
||||
@@ -1786,7 +1874,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
connectionsRef.current = connections;
|
||||
}, [connections]);
|
||||
|
||||
const refreshObjectDecorations = useCallback(() => {
|
||||
const refreshObjectDecorations = useCallback((maxTextLength = QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH) => {
|
||||
const editor = editorRef.current;
|
||||
const monaco = monacoRef.current;
|
||||
const model = editor?.getModel?.();
|
||||
@@ -1794,16 +1882,26 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return;
|
||||
}
|
||||
|
||||
const text = String(model.getValue?.() || '');
|
||||
const text = getQueryEditorModelTextIfWithinLimit(model, maxTextLength);
|
||||
if (text === null) {
|
||||
objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const decorations: any[] = [];
|
||||
const seen = new Set<string>();
|
||||
let scannedIdentifiers = 0;
|
||||
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) => {
|
||||
for (const [lineIndex, lineContent] of lines.entries()) {
|
||||
let match: RegExpExecArray | null;
|
||||
identifierRegex.lastIndex = 0;
|
||||
while ((match = identifierRegex.exec(lineContent)) !== null) {
|
||||
scannedIdentifiers += 1;
|
||||
if (scannedIdentifiers > QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS) {
|
||||
break;
|
||||
}
|
||||
const positionColumn = match.index + 2;
|
||||
const hoverTarget = resolveQueryEditorHoverTarget(
|
||||
text,
|
||||
@@ -1838,7 +1936,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
options: { inlineClassName },
|
||||
});
|
||||
}
|
||||
});
|
||||
if (scannedIdentifiers > QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, decorations);
|
||||
}, []);
|
||||
@@ -1852,8 +1953,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return false;
|
||||
}
|
||||
const lineContent = String(model.getLineContent?.(normalizedPosition.lineNumber) || '');
|
||||
const resolveText = getQueryEditorObjectResolveText(model, lineContent);
|
||||
const hoverTarget = resolveQueryEditorHoverTarget(
|
||||
String(model.getValue?.() || ''),
|
||||
resolveText,
|
||||
lineContent,
|
||||
normalizedPosition.column,
|
||||
currentDbRef.current,
|
||||
@@ -1896,8 +1998,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshObjectDecorations();
|
||||
}, [query, currentDb, refreshObjectDecorations]);
|
||||
refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH);
|
||||
}, [currentDb, refreshObjectDecorations]);
|
||||
|
||||
const getCurrentQuery = () => {
|
||||
const val = editorRef.current?.getValue?.();
|
||||
@@ -1932,7 +2034,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
const syncQueryToEditor = (sql: string) => {
|
||||
const next = sql || '';
|
||||
setQuery(next);
|
||||
applyQueryState(next);
|
||||
const editor = editorRef.current;
|
||||
if (editor && editor.getValue?.() !== next) {
|
||||
editor.setValue(next);
|
||||
@@ -1986,8 +2088,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
setDbList(dbs);
|
||||
if (!currentDbRef.current) {
|
||||
if (conn.config.database && dbs.includes(conn.config.database)) setCurrentDb(conn.config.database);
|
||||
else if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]);
|
||||
const configuredDb = String(conn.config.database || '').trim();
|
||||
const fallbackDb = dbs.find((db: string) => String(db || '').toLowerCase() !== 'information_schema') || dbs[0] || '';
|
||||
const nextDb = configuredDb && dbs.includes(configuredDb) ? configuredDb : fallbackDb;
|
||||
if (nextDb) {
|
||||
currentDbRef.current = nextDb;
|
||||
setCurrentDb(nextDb);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
visibleDbsRef.current = [];
|
||||
@@ -2215,26 +2322,82 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
setCurrentQueryId('');
|
||||
};
|
||||
|
||||
// Handle Resizing
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
const clampEditorHeight = useCallback((height: number) => {
|
||||
const viewportHeight = Number.isFinite(window.innerHeight) ? window.innerHeight : 800;
|
||||
const maxHeight = Math.max(100, viewportHeight - 200);
|
||||
return Math.max(100, Math.min(maxHeight, height));
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const applyEditorHeightToDom = useCallback(() => {
|
||||
const nextHeight = pendingEditorHeightRef.current;
|
||||
if (editorShellRef.current) {
|
||||
editorShellRef.current.style.height = `${nextHeight}px`;
|
||||
}
|
||||
editorRef.current?.layout?.();
|
||||
}, []);
|
||||
|
||||
const cancelEditorResizeFrame = useCallback(() => {
|
||||
if (resizeFrameRef.current === null) return;
|
||||
if (typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(resizeFrameRef.current);
|
||||
} else {
|
||||
window.clearTimeout(resizeFrameRef.current);
|
||||
}
|
||||
resizeFrameRef.current = null;
|
||||
}, []);
|
||||
|
||||
const scheduleEditorHeightDomUpdate = useCallback((height: number) => {
|
||||
pendingEditorHeightRef.current = height;
|
||||
if (resizeFrameRef.current !== null) return;
|
||||
|
||||
const requestFrame = typeof window.requestAnimationFrame === 'function'
|
||||
? window.requestAnimationFrame.bind(window)
|
||||
: (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 16);
|
||||
|
||||
resizeFrameRef.current = requestFrame(() => {
|
||||
resizeFrameRef.current = null;
|
||||
applyEditorHeightToDom();
|
||||
});
|
||||
}, [applyEditorHeightToDom]);
|
||||
|
||||
// Handle Resizing
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const delta = e.clientY - dragRef.current.startY;
|
||||
const newHeight = Math.max(100, Math.min(window.innerHeight - 200, dragRef.current.startHeight + delta));
|
||||
setEditorHeight(newHeight);
|
||||
};
|
||||
const newHeight = clampEditorHeight(dragRef.current.startHeight + delta);
|
||||
dragRef.current.currentHeight = newHeight;
|
||||
scheduleEditorHeightDomUpdate(newHeight);
|
||||
}, [clampEditorHeight, scheduleEditorHeightDomUpdate]);
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const handleMouseUp = useCallback(() => {
|
||||
const finalHeight = dragRef.current?.currentHeight;
|
||||
dragRef.current = null;
|
||||
cancelEditorResizeFrame();
|
||||
if (typeof finalHeight === 'number') {
|
||||
pendingEditorHeightRef.current = finalHeight;
|
||||
applyEditorHeightToDom();
|
||||
setEditorHeight(finalHeight);
|
||||
}
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [applyEditorHeightToDom, cancelEditorResizeFrame, handleMouseMove]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragRef.current = { startY: e.clientY, startHeight: editorHeight, currentHeight: editorHeight };
|
||||
pendingEditorHeightRef.current = editorHeight;
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [editorHeight, handleMouseMove, handleMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dragRef.current = null;
|
||||
cancelEditorResizeFrame();
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [cancelEditorResizeFrame, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// Setup Autocomplete and Editor
|
||||
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
||||
@@ -2243,9 +2406,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
lastEditorCursorPositionRef.current = normalizeEditorPosition(editor.getPosition?.());
|
||||
|
||||
editor.updateOptions?.({
|
||||
fixedOverflowWidgets: true,
|
||||
hover: {
|
||||
enabled: true,
|
||||
delay: QUERY_EDITOR_HOVER_DELAY_MS,
|
||||
above: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2283,7 +2448,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return;
|
||||
}
|
||||
const hoverTarget = resolveQueryEditorHoverTarget(
|
||||
String(model?.getValue?.() || ''),
|
||||
getQueryEditorObjectResolveText(model, lineContent),
|
||||
lineContent,
|
||||
targetPosition.column,
|
||||
currentDbRef.current,
|
||||
@@ -2356,8 +2521,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return null;
|
||||
}
|
||||
const lineContent = String(model?.getLineContent?.(normalizedPosition.lineNumber) || '');
|
||||
const resolveText = getQueryEditorObjectResolveText(model, lineContent);
|
||||
const hoverTarget = resolveQueryEditorHoverTarget(
|
||||
String(model?.getValue?.() || ''),
|
||||
resolveText,
|
||||
lineContent,
|
||||
normalizedPosition.column,
|
||||
currentDbRef.current,
|
||||
@@ -2411,8 +2577,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent?.(() => {
|
||||
refreshObjectDecorations();
|
||||
editor.onDidChangeModelContent?.((event: any) => {
|
||||
const hasSlashCommandMarker = Array.isArray(event?.changes)
|
||||
&& event.changes.some((change: any) => /__AI_\w+__/.test(String(change?.text || '')));
|
||||
if (!hasSlashCommandMarker) return;
|
||||
refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH);
|
||||
});
|
||||
|
||||
editor.onMouseMove?.((event: any) => {
|
||||
@@ -2672,14 +2841,21 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
}
|
||||
|
||||
// HMR 重载或测试重置时,以全局状态为准,避免本地闭包状态和 provider 列表不同步。
|
||||
sqlCompletionRegistered = Boolean(_g.__gonaviSqlCompletionState.registered);
|
||||
sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables;
|
||||
const shouldRegisterSqlCompletion = !sqlCompletionRegistered
|
||||
|| _g.__gonaviSqlCompletionState.version !== SQL_COMPLETION_PROVIDER_VERSION;
|
||||
|
||||
// HMR 重载时释放旧注册避免补全项重复
|
||||
if (!sqlCompletionRegistered) {
|
||||
if (shouldRegisterSqlCompletion) {
|
||||
sqlCompletionRegistered = true;
|
||||
_g.__gonaviSqlCompletionState.registered = true;
|
||||
_g.__gonaviSqlCompletionState.version = SQL_COMPLETION_PROVIDER_VERSION;
|
||||
sqlCompletionDisposables.forEach((d: any) => d?.dispose?.());
|
||||
sqlCompletionDisposables.length = 0;
|
||||
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
|
||||
triggerCharacters: ['.'],
|
||||
triggerCharacters: ['.', '_', ...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')],
|
||||
provideCompletionItems: async (model: any, position: any) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
@@ -2716,6 +2892,45 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
};
|
||||
};
|
||||
|
||||
const getLazyTablesByDB = async (dbName: string) => {
|
||||
const connId = sharedCurrentConnectionId;
|
||||
if (!connId || !dbName) return [] as CompletionTableMeta[];
|
||||
const key = `${connId}|${dbName}`;
|
||||
if (sharedLazyTablesCache[key]) {
|
||||
return sharedLazyTablesCache[key];
|
||||
}
|
||||
if (sharedLazyTablesInFlight[key]) {
|
||||
return sharedLazyTablesInFlight[key];
|
||||
}
|
||||
|
||||
const config = buildConnConfig();
|
||||
if (!config) return [] as CompletionTableMeta[];
|
||||
|
||||
sharedLazyTablesInFlight[key] = DBGetTables(buildRpcConnectionConfig(config) as any, dbName)
|
||||
.then((res) => {
|
||||
const tables = res?.success && Array.isArray(res.data)
|
||||
? res.data
|
||||
.map((row: any) => String(Object.values(row || {})[0] || '').trim())
|
||||
.filter(Boolean)
|
||||
.map((tableName: string) => ({ dbName, tableName }))
|
||||
: [];
|
||||
sharedLazyTablesCache[key] = tables;
|
||||
if (tables.length > 0) {
|
||||
const existingKeys = new Set(sharedTablesData.map((table) => `${table.dbName.toLowerCase()}.${table.tableName.toLowerCase()}`));
|
||||
const missingTables = tables.filter((table) => !existingKeys.has(`${table.dbName.toLowerCase()}.${table.tableName.toLowerCase()}`));
|
||||
if (missingTables.length > 0) {
|
||||
sharedTablesData = [...sharedTablesData, ...missingTables];
|
||||
}
|
||||
}
|
||||
return tables;
|
||||
})
|
||||
.catch(() => [])
|
||||
.finally(() => {
|
||||
delete sharedLazyTablesInFlight[key];
|
||||
});
|
||||
return sharedLazyTablesInFlight[key];
|
||||
};
|
||||
|
||||
const getColumnsByDB = async (tableIdent: string) => {
|
||||
const connId = sharedCurrentConnectionId;
|
||||
const dbName = sharedCurrentDb;
|
||||
@@ -2920,6 +3135,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const currentDatabase = sharedCurrentDb || '';
|
||||
const wordPrefix = (word.word || '').toLowerCase();
|
||||
const startsWithPrefix = (candidate: string) => !wordPrefix || candidate.toLowerCase().startsWith(wordPrefix);
|
||||
const includesWordPrefix = (candidate: string) => !wordPrefix || String(candidate || '').toLowerCase().includes(wordPrefix);
|
||||
const getPrefixMatchRank = (...candidates: string[]) => {
|
||||
if (!wordPrefix) return '0';
|
||||
const normalized = candidates
|
||||
.map((candidate) => String(candidate || '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (normalized.some((candidate) => candidate.startsWith(wordPrefix))) return '0';
|
||||
if (normalized.some((candidate) => candidate.includes(wordPrefix))) return '1';
|
||||
return '9';
|
||||
};
|
||||
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim());
|
||||
const shouldBoostKeywords = !expectsTableName
|
||||
&& wordPrefix.length > 0
|
||||
@@ -2929,10 +3154,27 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
: expectsTableName
|
||||
? { keyword: '20', func: '25', columnCurrent: '10', columnOther: '11', tableCurrent: '00', tableOther: '01', db: '30' }
|
||||
: { keyword: '30', func: '25', columnCurrent: '00', columnOther: '01', tableCurrent: '10', tableOther: '11', db: '20' };
|
||||
let completionTables = sharedTablesData;
|
||||
if (
|
||||
expectsTableName
|
||||
&& currentDatabase
|
||||
&& !sharedTablesData.some((t) => (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase())
|
||||
) {
|
||||
const lazyTables = await getLazyTablesByDB(currentDatabase);
|
||||
if (lazyTables.length > 0) {
|
||||
const seenTableKeys = new Set<string>();
|
||||
completionTables = [...sharedTablesData, ...lazyTables].filter((table) => {
|
||||
const key = `${String(table.dbName || '').toLowerCase()}.${String(table.tableName || '').toLowerCase()}`;
|
||||
if (seenTableKeys.has(key)) return false;
|
||||
seenTableKeys.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 相关列提示:匹配 SQL 中引用的表(FROM/JOIN 等)
|
||||
// 权重最高,输入 WHERE 条件时优先显示
|
||||
const relevantColumns = sharedAllColumnsData
|
||||
const relevantColumns = (expectsTableName ? [] : sharedAllColumnsData)
|
||||
.filter(c => {
|
||||
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
|
||||
const shortIdent = (c.tableName || '').toLowerCase();
|
||||
@@ -2957,7 +3199,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
// 表提示:当前库智能处理 schema.table 格式
|
||||
// 1. 构建纯表名到 schema 列表的映射,检测同名表
|
||||
const currentDbTables = sharedTablesData.filter(t =>
|
||||
const currentDbTables = completionTables.filter(t =>
|
||||
(t.dbName || '').toLowerCase() === currentDatabase.toLowerCase()
|
||||
);
|
||||
const tableNameToSchemas = new Map<string, string[]>();
|
||||
@@ -2969,20 +3211,24 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
tableNameToSchemas.set(pureTable, schemas);
|
||||
}
|
||||
|
||||
const tableSuggestions = sharedTablesData
|
||||
const tableSuggestions = completionTables
|
||||
.filter(t => {
|
||||
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
|
||||
if (!isCurrentDb) {
|
||||
// 跨库:用 db.table 格式匹配
|
||||
return startsWithPrefix(`${t.dbName}.${t.tableName}`);
|
||||
}
|
||||
// 当前库:同时用完整名和纯表名匹配
|
||||
const parsed = splitSchemaAndTable(t.tableName || '');
|
||||
const pureTable = parsed.table || t.tableName || '';
|
||||
return startsWithPrefix(t.tableName || '') || startsWithPrefix(pureTable);
|
||||
if (!isCurrentDb) {
|
||||
// 跨库:用 db.table 格式匹配
|
||||
return includesWordPrefix(`${t.dbName}.${t.tableName}`)
|
||||
|| includesWordPrefix(t.tableName || '')
|
||||
|| includesWordPrefix(pureTable);
|
||||
}
|
||||
// 当前库:同时用完整名和纯表名匹配
|
||||
return includesWordPrefix(t.tableName || '') || includesWordPrefix(pureTable);
|
||||
})
|
||||
.map(t => {
|
||||
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
|
||||
const parsed = splitSchemaAndTable(t.tableName || '');
|
||||
const pureTable = parsed.table || t.tableName || '';
|
||||
if (!isCurrentDb) {
|
||||
const label = `${t.dbName}.${t.tableName}`;
|
||||
return {
|
||||
@@ -2992,12 +3238,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
detail: appendCommentToDetail(`Table (${t.dbName})`, t.comment),
|
||||
documentation: buildCompletionDocumentation(t.comment),
|
||||
range,
|
||||
sortText: sortGroups.tableOther + t.tableName,
|
||||
sortText: sortGroups.tableOther + getPrefixMatchRank(`${t.dbName}.${t.tableName}`, t.tableName || '', pureTable) + t.tableName,
|
||||
};
|
||||
}
|
||||
// 当前库:检查是否有跨 schema 同名表
|
||||
const parsed = splitSchemaAndTable(t.tableName || '');
|
||||
const pureTable = parsed.table || t.tableName || '';
|
||||
const schemas = tableNameToSchemas.get(pureTable.toLowerCase()) || [];
|
||||
const hasDuplicate = schemas.length > 1;
|
||||
// 同名表存在于多个 schema → 显示 schema.table;否则只显示纯表名
|
||||
@@ -3011,7 +3255,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
detail: appendCommentToDetail(`Table${schemaInfo}`, t.comment),
|
||||
documentation: buildCompletionDocumentation(t.comment),
|
||||
range,
|
||||
sortText: sortGroups.tableCurrent + pureTable,
|
||||
sortText: sortGroups.tableCurrent + getPrefixMatchRank(t.tableName || '', pureTable) + pureTable,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3144,8 +3388,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
// 每个编辑器实例都注册内容变化监听(检测斜杠命令标记)
|
||||
let _handlingSlash = false;
|
||||
editor.onDidChangeModelContent(() => {
|
||||
editor.onDidChangeModelContent((event: any) => {
|
||||
if (_handlingSlash) return;
|
||||
const hasSlashCommandMarker = Array.isArray(event?.changes)
|
||||
&& event.changes.some((change: any) => /__AI_\w+__/.test(String(change?.text || '')));
|
||||
if (!hasSlashCommandMarker) return;
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const content = model.getValue();
|
||||
@@ -3206,7 +3453,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}]);
|
||||
editor.pushUndoStop?.();
|
||||
const nextValue = editor.getValue?.();
|
||||
setQuery(typeof nextValue === 'string' ? nextValue : formatted);
|
||||
applyQueryState(typeof nextValue === 'string' ? nextValue : formatted);
|
||||
refreshObjectDecorations();
|
||||
return;
|
||||
}
|
||||
@@ -3420,6 +3667,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return merged;
|
||||
};
|
||||
|
||||
const resolveActiveResultKeyAfterMerge = (merged: ResultSet[], executed: ResultSet[]): string => {
|
||||
const firstExecutedResult = executed[0];
|
||||
if (!firstExecutedResult) {
|
||||
return '';
|
||||
}
|
||||
const executedSqlKey = normalizeExecutedSqlKey(firstExecutedResult.exportSql || firstExecutedResult.sql);
|
||||
return merged.find((item) => normalizeExecutedSqlKey(item.exportSql || item.sql) === executedSqlKey)?.key
|
||||
|| firstExecutedResult.key
|
||||
|| merged[0]?.key
|
||||
|| '';
|
||||
};
|
||||
|
||||
const resolveExecutableSQLAtEditorPosition = (model: any, sqlText: string, position: any): string => {
|
||||
const normalizedPosition = normalizeEditorPosition(position);
|
||||
if (!normalizedPosition) return '';
|
||||
@@ -3557,8 +3816,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const executableSQL = getExecutableSQL();
|
||||
if (!executableSQL.trim()) {
|
||||
message.info('没有可执行的 SQL。');
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
return;
|
||||
}
|
||||
if (!currentDb) {
|
||||
@@ -3631,8 +3888,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
);
|
||||
if (statements.length === 0) {
|
||||
message.info('没有可执行的 SQL。');
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3734,8 +3989,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
}
|
||||
const shouldReplaceAllResults = didExecuteWholeEditor;
|
||||
setResultSets(prev => mergeResultSets(prev, nextResultSets, shouldReplaceAllResults));
|
||||
setActiveResultKey(nextResultSets[0]?.key || '');
|
||||
setResultSets(prev => {
|
||||
const merged = mergeResultSets(prev, nextResultSets, shouldReplaceAllResults);
|
||||
setActiveResultKey(resolveActiveResultKeyAfterMerge(merged, nextResultSets));
|
||||
return merged;
|
||||
});
|
||||
if (didExecuteAppendedSql || didExecuteWholeEditor) {
|
||||
lastExecutedEditorQueryRef.current = currentQuery;
|
||||
}
|
||||
@@ -3758,8 +4016,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
);
|
||||
if (sourceStatements.length === 0) {
|
||||
message.info('没有可执行的 SQL。');
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3902,8 +4158,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
|
||||
const shouldReplaceAllResults = didExecuteWholeEditor;
|
||||
setResultSets(prev => mergeResultSets(prev, nextResultSets, shouldReplaceAllResults));
|
||||
setActiveResultKey(nextResultSets[0]?.key || '');
|
||||
setResultSets(prev => {
|
||||
const merged = mergeResultSets(prev, nextResultSets, shouldReplaceAllResults);
|
||||
setActiveResultKey(resolveActiveResultKeyAfterMerge(merged, nextResultSets));
|
||||
return merged;
|
||||
});
|
||||
if (didExecuteAppendedSql || didExecuteWholeEditor) {
|
||||
lastExecutedEditorQueryRef.current = currentQuery;
|
||||
}
|
||||
@@ -3928,7 +4187,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-error`,
|
||||
timestamp: Date.now(),
|
||||
sql: executableSQL || getExecutableSQL() || query,
|
||||
sql: executableSQL || getExecutableSQL() || getCurrentQuery(),
|
||||
status: 'error',
|
||||
duration: Date.now() - runStartTime,
|
||||
message: e.message,
|
||||
@@ -4199,7 +4458,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}]);
|
||||
const nextValue = editor.getValue?.();
|
||||
if (typeof nextValue === 'string') {
|
||||
setQuery(nextValue);
|
||||
applyQueryState(nextValue);
|
||||
}
|
||||
|
||||
// 定位并滚动到可见区域
|
||||
@@ -4224,7 +4483,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setQuery((prev: string) => prev ? prev + '\n' + sqlText : sqlText);
|
||||
applyQueryState(getCurrentQuery() ? `${getCurrentQuery()}\n${sqlText}` : sqlText);
|
||||
message.success('代码已追加');
|
||||
}
|
||||
};
|
||||
@@ -4286,6 +4545,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
filePath,
|
||||
savedQueryId: undefined,
|
||||
});
|
||||
setSQLFileTabDraft(tab.id, sql);
|
||||
message.success('SQL 文件已保存!');
|
||||
} catch (error) {
|
||||
message.error('保存 SQL 文件失败: ' + (error instanceof Error ? error.message : String(error)));
|
||||
@@ -4424,6 +4684,65 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
});
|
||||
};
|
||||
|
||||
const replaceResultSetsAfterMenuClose = (next: ResultSet[], preferredKey?: string) => {
|
||||
setResultSets(next);
|
||||
setActiveResultKey(prevActive => {
|
||||
if (preferredKey && next.some(result => result.key === preferredKey)) return preferredKey;
|
||||
if (prevActive && next.some(result => result.key === prevActive)) return prevActive;
|
||||
return next[0]?.key || '';
|
||||
});
|
||||
};
|
||||
|
||||
const closeOtherResultTabs = (key: string) => {
|
||||
const target = resultSets.find(result => result.key === key);
|
||||
replaceResultSetsAfterMenuClose(target ? [target] : resultSets, key);
|
||||
};
|
||||
|
||||
const closeResultTabsToLeft = (key: string) => {
|
||||
const index = resultSets.findIndex(result => result.key === key);
|
||||
if (index <= 0) return;
|
||||
replaceResultSetsAfterMenuClose(resultSets.slice(index), key);
|
||||
};
|
||||
|
||||
const closeResultTabsToRight = (key: string) => {
|
||||
const index = resultSets.findIndex(result => result.key === key);
|
||||
if (index < 0 || index >= resultSets.length - 1) return;
|
||||
replaceResultSetsAfterMenuClose(resultSets.slice(0, index + 1), key);
|
||||
};
|
||||
|
||||
const closeAllResultTabs = () => {
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
};
|
||||
|
||||
const buildResultTabMenuItems = (key: string, index: number): MenuProps['items'] => [
|
||||
{
|
||||
key: 'close-other',
|
||||
label: '关闭其他页',
|
||||
disabled: resultSets.length <= 1,
|
||||
onClick: () => closeOtherResultTabs(key),
|
||||
},
|
||||
{
|
||||
key: 'close-left',
|
||||
label: '关闭左侧',
|
||||
disabled: index <= 0,
|
||||
onClick: () => closeResultTabsToLeft(key),
|
||||
},
|
||||
{
|
||||
key: 'close-right',
|
||||
label: '关闭右侧',
|
||||
disabled: index >= resultSets.length - 1,
|
||||
onClick: () => closeResultTabsToRight(key),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'close-all',
|
||||
label: '关闭所有',
|
||||
disabled: resultSets.length === 0,
|
||||
onClick: closeAllResultTabs,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={queryEditorRootRef} className={isV2Ui ? 'gn-v2-query-editor' : undefined} style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<style>{`
|
||||
@@ -4437,16 +4756,42 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
.query-result-tabs .ant-tabs-nav {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
min-height: 38px;
|
||||
}
|
||||
.query-result-tabs .ant-tabs-nav-wrap {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.query-result-tabs .ant-tabs-nav-list {
|
||||
align-items: stretch;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
}
|
||||
.query-result-tabs .ant-tabs-tab {
|
||||
min-height: 34px;
|
||||
padding: 4px 10px !important;
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 148px !important;
|
||||
height: 30px !important;
|
||||
min-height: 30px;
|
||||
margin: 4px 6px 4px 0 !important;
|
||||
padding: 0 9px !important;
|
||||
border-radius: 999px !important;
|
||||
border: 0.5px solid transparent !important;
|
||||
border-right: 0.5px solid transparent !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.query-result-tabs .ant-tabs-tab-btn {
|
||||
width: auto !important;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
.query-result-tabs .ant-tabs-tab.ant-tabs-tab-active::after {
|
||||
display: none;
|
||||
}
|
||||
.query-result-tabs .ant-tabs-content-holder {
|
||||
flex: 1 1 auto;
|
||||
@@ -4481,16 +4826,36 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
.query-result-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
line-height: 1.1;
|
||||
max-width: 126px;
|
||||
height: 100%;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.query-result-tab-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.query-result-tab-count {
|
||||
flex: 0 0 auto;
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 17px;
|
||||
}
|
||||
.query-result-tab-close {
|
||||
display: inline-flex;
|
||||
@@ -4598,19 +4963,30 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div className={isV2Ui ? 'gn-v2-query-monaco-shell' : undefined} style={{ height: editorHeight, minHeight: '100px' }}>
|
||||
<div ref={editorShellRef} className={isV2Ui ? 'gn-v2-query-monaco-shell' : undefined} style={{ height: editorHeight, minHeight: '100px' }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
gonaviTypography="code"
|
||||
defaultLanguage="sql"
|
||||
theme={darkMode ? "transparent-dark" : "transparent-light"}
|
||||
defaultValue={query}
|
||||
onChange={(val) => setQuery(val || '')}
|
||||
onChange={(val) => {
|
||||
const nextValue = val || '';
|
||||
syncQueryDraft(nextValue);
|
||||
}}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
automaticLayout: true,
|
||||
fixedOverflowWidgets: true,
|
||||
hover: {
|
||||
enabled: true,
|
||||
delay: QUERY_EDITOR_HOVER_DELAY_MS,
|
||||
above: false,
|
||||
},
|
||||
scrollBeyondLastLine: false,
|
||||
quickSuggestions: { other: true, comments: false, strings: false },
|
||||
suggestOnTriggerCharacters: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -4640,27 +5016,44 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
items={resultSets.map((rs, idx) => ({
|
||||
key: rs.key,
|
||||
label: (
|
||||
<div className="query-result-tab-label">
|
||||
<Tooltip title={rs.sql}>
|
||||
<span className="query-result-tab-text">{(() => {
|
||||
const isAffected = rs.columns.length === 1 && rs.columns[0] === 'affectedRows';
|
||||
if (isAffected) return `结果 ${idx + 1} ✓`;
|
||||
return `结果 ${idx + 1}${Array.isArray(rs.rows) ? ` (${rs.rows.length})` : ''}`;
|
||||
})()}</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="关闭结果">
|
||||
<span
|
||||
className="query-result-tab-close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCloseResult(rs.key);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Dropdown
|
||||
menu={{ items: buildResultTabMenuItems(rs.key, idx) }}
|
||||
trigger={['contextMenu']}
|
||||
rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}
|
||||
>
|
||||
<div
|
||||
className="query-result-tab-label"
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Tooltip title={rs.sql}>
|
||||
<span className="query-result-tab-text">结果 {idx + 1}</span>
|
||||
</Tooltip>
|
||||
{(() => {
|
||||
const isAffected = rs.columns.length === 1 && rs.columns[0] === 'affectedRows';
|
||||
if (isAffected) {
|
||||
return <span className="query-result-tab-count">✓</span>;
|
||||
}
|
||||
if (!Array.isArray(rs.rows)) {
|
||||
return null;
|
||||
}
|
||||
return <span className="query-result-tab-count">{rs.rows.length}</span>;
|
||||
})()}
|
||||
<Tooltip title="关闭结果">
|
||||
<span
|
||||
className="query-result-tab-close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCloseResult(rs.key);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Dropdown>
|
||||
),
|
||||
children: (() => {
|
||||
// affectedRows 类型结果集(UPDATE/INSERT/DELETE):简洁提示
|
||||
|
||||
Reference in New Issue
Block a user