From 2afddf497b87814a8a87adb9106ceb97a30efefa Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 2 Jun 2026 11:16:52 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20SQL=20=E8=A1=A5=E5=85=A8=E5=92=8C=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E9=A1=B5=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复新建查询页输入后表名补全失效,支持当前库懒加载与模糊匹配 - 限制长 SQL 实时装饰和持久化草稿,降低输入卡顿 - 执行相同格式化 SQL 时复用结果页并聚焦对应结果标签 - 查询结果标签增加右键关闭菜单并优化标签样式和选中文字行为 --- .../QueryEditor.external-sql-save.test.tsx | 559 ++++++++++++++++- frontend/src/components/QueryEditor.tsx | 583 +++++++++++++++--- 2 files changed, 1038 insertions(+), 104 deletions(-) diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 835d112..bc05b48 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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 => ({ 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(); + }); + 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(); + }); + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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 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( { })} />); }); + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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 () => { diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 920fef3..9f11d34 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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 = {}; +const sharedLazyTablesCache: Record = {}; +const sharedLazyTablesInFlight: Record | 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(null); const editorRef = useRef(null); const monacoRef = useRef(null); const runQueryActionRef = useRef(null); @@ -1677,7 +1723,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const objectDecorationIdsRef = useRef([]); const objectHoverActionRef = useRef(null); const hoverProviderDisposableRef = useRef(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(null); const queryEditorRootRef = useRef(null); const editorPaneRef = useRef(null); const tablesRef = useRef([]); // 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(); + 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(); + 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(); @@ -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 (