🐛 fix(query-editor): 优化 SQL 补全和结果页交互

- 修复新建查询页输入后表名补全失效,支持当前库懒加载与模糊匹配

- 限制长 SQL 实时装饰和持久化草稿,降低输入卡顿

- 执行相同格式化 SQL 时复用结果页并聚焦对应结果标签

- 查询结果标签增加右键关闭菜单并优化标签样式和选中文字行为
This commit is contained in:
Syngnat
2026-06-02 11:16:52 +08:00
parent c405eb08b5
commit 2afddf497b
2 changed files with 1038 additions and 104 deletions

View File

@@ -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 () => {

View File

@@ -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简洁提示