diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 8c9f739..c97ea50 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -71,6 +71,7 @@ const storeState = vi.hoisted(() => ({ activeTabId: 'tab-1', aiPanelVisible: false, setAIPanelVisible: vi.fn(), + sqlSnippets: [] as any[], })); const backendApp = vi.hoisted(() => ({ @@ -827,6 +828,73 @@ describe('QueryEditor external SQL save', () => { renderer.unmount(); }); + it('registers all SQL completion providers in the disposable singleton state', async () => { + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + const completionState = (globalThis as any).__gonaviSqlCompletionState; + + expect(editorState.hoverProviders).toHaveLength(1); + expect(editorState.providers).toHaveLength(3); + expect(completionState.disposables).toHaveLength(4); + + await act(async () => { + renderer.unmount(); + }); + }); + + it('keeps plain typing out of SQL completion trigger characters', async () => { + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.')); + + expect(sqlProvider).toBeTruthy(); + expect(sqlProvider.triggerCharacters).toEqual(['.']); + expect(sqlProvider.triggerCharacters).not.toContain('s'); + + await act(async () => { + renderer.unmount(); + }); + }); + + it('drops cancelled SQL completion requests while the user keeps typing', async () => { + let renderer!: ReactTestRenderer; + backendApp.DBGetTables.mockResolvedValueOnce({ + success: true, + data: [{ Table: 'session_log' }], + }); + + 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 ss'; + editorState.position = { lineNumber: 1, column: editorState.value.length + 1 }; + editorState.latestOnChange?.(editorState.value); + + const result = await sqlProvider.provideCompletionItems( + editorState.editor.getModel(), + editorState.position, + undefined, + { isCancellationRequested: true }, + ); + + expect(result.suggestions).toEqual([]); + expect(backendApp.DBGetTables).not.toHaveBeenCalled(); + + await act(async () => { + renderer.unmount(); + }); + }); + it('keeps table name completion available after typing in a fresh query tab', async () => { let renderer!: ReactTestRenderer; autoFetchState.visible = true; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 710d7b1..5283ac0 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -47,7 +47,7 @@ import { useSqlEditorTransactionController } from './useSqlEditorTransactionCont // HMR 重载时释放旧注册避免补全和 hover 内容重复 const _g = globalThis as any; -const SQL_COMPLETION_PROVIDER_VERSION = '20260603-hover-singleton-v1'; +const SQL_COMPLETION_PROVIDER_VERSION = '20260612-cursor-stable-completion-v1'; if (!_g.__gonaviSqlCompletionState) { _g.__gonaviSqlCompletionState = { registered: false, version: '', disposables: [] as any[] }; } @@ -77,6 +77,9 @@ let sharedRoutinesData: CompletionRoutineMeta[] = []; let sharedColumnsCacheData: Record = {}; const sharedLazyTablesCache: Record = {}; const sharedLazyTablesInFlight: Record | undefined> = {}; +const createEmptySqlCompletionResult = () => ({ suggestions: [] as any[] }); +const isSqlCompletionRequestCancelled = (token?: { isCancellationRequested?: boolean } | null) => + Boolean(token?.isCancellationRequested); const QUERY_LOCATOR_ALIAS_PREFIX = '__gonavi_locator_'; @@ -3077,8 +3080,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }, })); sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', { - triggerCharacters: ['.', '_', ...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')], - provideCompletionItems: async (model: any, position: any) => { + triggerCharacters: ['.'], + provideCompletionItems: async (model: any, position: any, _context?: any, token?: { isCancellationRequested?: boolean }) => { + if (isSqlCompletionRequestCancelled(token)) { + return createEmptySqlCompletionResult(); + } const word = model.getWordUntilPosition(position); const range = { startLineNumber: position.lineNumber, @@ -3320,6 +3326,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc .map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName, comment: c.comment })); } else { const dbCols = await getColumnsByDB(tableInfo.tableName); + if (isSqlCompletionRequestCancelled(token)) { + return createEmptySqlCompletionResult(); + } cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName, comment: c.comment })); } @@ -3383,6 +3392,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc && !sharedTablesData.some((t) => (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase()) ) { const lazyTables = await getLazyTablesByDB(currentDatabase); + if (isSqlCompletionRequestCancelled(token)) { + return createEmptySqlCompletionResult(); + } if (lazyTables.length > 0) { const seenTableKeys = new Set(); completionTables = [...sharedTablesData, ...lazyTables].filter((table) => { @@ -3572,11 +3584,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // SQL snippet completion provider - monaco.languages.registerCompletionItemProvider('sql', { + sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', { provideCompletionItems: (model: any, position: any) => { const word = model.getWordUntilPosition(position); const prefix = word.word.toLowerCase(); - if (!prefix) return { suggestions: [] }; + if (!prefix) return createEmptySqlCompletionResult(); const range = { startLineNumber: position.lineNumber, @@ -3585,7 +3597,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc endColumn: word.endColumn, }; - const allSnippets = useStore.getState().sqlSnippets; + const allSnippets = useStore.getState().sqlSnippets || []; const matched = allSnippets.filter(s => s.prefix.toLowerCase().startsWith(prefix) || s.name.toLowerCase().includes(prefix) @@ -3604,7 +3616,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc })), }; }, - }); + })); } // end sqlCompletionRegistered guard