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