mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 02:19:58 +08:00
🐛 fix(sql-editor): 修复补全提示下连续输入光标跳转
- 调整 SQL 补全触发策略,避免普通字母输入高频触发 provider - 支持 Monaco 补全取消 token,丢弃连续输入时的过期异步请求 - 将 SQL snippet provider 纳入统一 disposable 管理,避免重复注册残留 - 补充 QueryEditor 回归测试覆盖补全触发、取消和释放链路 Refs #504
This commit is contained in:
@@ -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(<QueryEditor tab={createTab({ query: '' })} />);
|
||||
});
|
||||
|
||||
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(<QueryEditor tab={createTab({ query: '' })} />);
|
||||
});
|
||||
|
||||
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(<QueryEditor tab={createTab({ query: '', dbName: 'main' })} />);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<string, any[]> = {};
|
||||
const sharedLazyTablesCache: Record<string, CompletionTableMeta[] | undefined> = {};
|
||||
const sharedLazyTablesInFlight: Record<string, Promise<CompletionTableMeta[]> | 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<string>();
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user