🐛 fix(query-editor): 修复快捷执行与 Windows 输入丢字

- 快捷执行仅由 Monaco 编辑器处理,保留 Shift+Home 反向选区和光标位置
- 缓冲并恢复 Windows 下丢失的可打印输入和延迟 IME 提交
- 降低 AI 行内补全预检与元数据刷新造成的编辑器输入抖动
- 补充快捷键、输入回退和 AI 补全回归测试
This commit is contained in:
Syngnat
2026-07-10 18:09:56 +08:00
parent 18f40753dd
commit be09808d1f
8 changed files with 812 additions and 40 deletions

View File

@@ -497,6 +497,36 @@ describe('QueryEditorAiAssist', () => {
expect(missingProvider.reason).toBe('provider_missing');
});
it('caches unavailable inline AI readiness across adjacent automatic requests', async () => {
const service: QueryEditorAiService = {
AIChatSend: vi.fn(),
AIGetProviders: vi.fn(async () => []),
AIGetActiveProvider: vi.fn(async () => ''),
};
const request = {
service,
aiContext: {
connectionName: 'Local MySQL',
sourceType: 'mysql',
currentDb: 'shop',
tables: [],
columns: [],
},
editorSnapshot: {
prefix: 'select * from users ',
suffix: '',
currentLineBeforeCursor: 'select * from users ',
currentLineAfterCursor: '',
},
};
await requestQueryEditorInlineCompletion(request);
await requestQueryEditorInlineCompletion(request);
expect(service.AIGetProviders).toHaveBeenCalledTimes(1);
expect(service.AIGetActiveProvider).toHaveBeenCalledTimes(1);
});
it('uses deterministic schema metadata for table-name inline completion and skips AI', async () => {
const service = readyService('SELECT * FROM orders;');
@@ -522,6 +552,8 @@ describe('QueryEditorAiAssist', () => {
expect(insertText).toBe('eos');
expect(service.AIChatSend).not.toHaveBeenCalled();
expect(service.AIGetProviders).not.toHaveBeenCalled();
expect(service.AIGetActiveProvider).not.toHaveBeenCalled();
});
it('uses deterministic schema metadata for alter-table inline completion and skips AI', async () => {

View File

@@ -107,6 +107,7 @@ const MAX_INLINE_INSERT_CHARS = 1800;
const MAX_INLINE_GHOST_PREVIEW_CHARS = 220;
const INLINE_COMPLETION_MAX_TOKENS = 192;
const INLINE_COMPLETION_TEMPERATURE = 0.1;
const INLINE_RUNTIME_READINESS_CACHE_TTL_MS = 5000;
const SQL_CODE_FENCE_RE = /```(?:sql|mysql|postgresql|postgres|oracle|plsql|sqlite|sqlserver|mssql|tsql|clickhouse|duckdb|starrocks|tdengine)?\s*([\s\S]*?)```/i;
const INLINE_TABLE_COMPLETION_RE = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|ALTER\s+TABLE|DROP\s+TABLE|TRUNCATE\s+TABLE)\s*([^\s,()]*)$/i;
@@ -174,6 +175,49 @@ export const resolveQueryEditorAiRuntimeReadiness = async (
};
};
type InlineRuntimeReadinessCacheEntry = {
expiresAt: number;
promise: Promise<QueryEditorAiRuntimeReadiness>;
};
let inlineRuntimeReadinessCache = new WeakMap<object, InlineRuntimeReadinessCacheEntry>();
export const clearQueryEditorInlineRuntimeReadinessCache = (): void => {
inlineRuntimeReadinessCache = new WeakMap<object, InlineRuntimeReadinessCacheEntry>();
};
if (typeof window !== 'undefined') {
window.addEventListener('gonavi:ai:provider-changed', clearQueryEditorInlineRuntimeReadinessCache);
window.addEventListener('gonavi:ai:config-changed', clearQueryEditorInlineRuntimeReadinessCache);
}
export const resolveQueryEditorInlineRuntimeReadiness = (
service: QueryEditorAiService | undefined,
): Promise<QueryEditorAiRuntimeReadiness> => {
if (!service || typeof service !== 'object') {
return resolveQueryEditorAiRuntimeReadiness(service, { requireInlineCompletionModel: true });
}
const now = Date.now();
const cached = inlineRuntimeReadinessCache.get(service);
if (cached && cached.expiresAt > now) {
return cached.promise;
}
const promise = resolveQueryEditorAiRuntimeReadiness(service, { requireInlineCompletionModel: true });
const entry = {
expiresAt: now + INLINE_RUNTIME_READINESS_CACHE_TTL_MS,
promise,
};
inlineRuntimeReadinessCache.set(service, entry);
void promise.catch(() => {
if (inlineRuntimeReadinessCache.get(service) === entry) {
inlineRuntimeReadinessCache.delete(service);
}
});
return promise;
};
export const shouldRequestQueryEditorInlineCompletion = (
snapshot: QueryEditorAiEditorSnapshot,
): boolean => {
@@ -259,6 +303,56 @@ export const resolveQueryEditorInlineMemoryInsertText = ({
return '';
};
export const resolveQueryEditorInlineLocalCompletion = ({
aiContext,
editorSnapshot,
deferEmptySchemaCompletion = false,
}: {
aiContext: QueryEditorAiContext;
editorSnapshot: QueryEditorAiEditorSnapshot;
deferEmptySchemaCompletion?: boolean;
}): { handled: boolean; insertText: string } => {
if (!shouldRequestQueryEditorInlineCompletion(editorSnapshot)) {
return {
handled: true,
insertText: '',
};
}
const inlineIntent = resolveQueryEditorInlineCompletionIntentDetails(editorSnapshot);
const deterministicCompletion = resolveDeterministicInlineSchemaCompletion(
aiContext,
editorSnapshot,
inlineIntent,
);
if (deterministicCompletion.handled && deterministicCompletion.insertText) {
return deterministicCompletion;
}
if (deterministicCompletion.handled) {
if (deferEmptySchemaCompletion) {
return {
handled: false,
insertText: '',
};
}
if (
(inlineIntent.intent === 'table_name'
&& !shouldAllowInlineTableAiFallback(aiContext, inlineIntent.fragment))
|| (inlineIntent.intent === 'column_name'
&& !shouldAllowInlineColumnAiFallback(
aiContext,
editorSnapshot,
inlineIntent.qualifier,
inlineIntent.fragment,
))
) {
return deterministicCompletion;
}
}
return resolveDeterministicInlineSyntaxCompletion(editorSnapshot);
};
export const requestQueryEditorInlineCompletion = async ({
service,
aiContext,
@@ -268,35 +362,12 @@ export const requestQueryEditorInlineCompletion = async ({
aiContext: QueryEditorAiContext;
editorSnapshot: QueryEditorAiEditorSnapshot;
}): Promise<string> => {
if (!shouldRequestQueryEditorInlineCompletion(editorSnapshot)) {
return '';
const localCompletion = resolveQueryEditorInlineLocalCompletion({ aiContext, editorSnapshot });
if (localCompletion.handled) {
return localCompletion.insertText;
}
const inlineIntent = resolveQueryEditorInlineCompletionIntentDetails(editorSnapshot);
const deterministicCompletion = resolveDeterministicInlineSchemaCompletion(aiContext, editorSnapshot, inlineIntent);
if (deterministicCompletion.handled && deterministicCompletion.insertText) {
return deterministicCompletion.insertText;
}
if (deterministicCompletion.handled) {
if (inlineIntent.intent === 'table_name') {
if (!shouldAllowInlineTableAiFallback(aiContext, inlineIntent.fragment)) {
return '';
}
} else if (inlineIntent.intent === 'column_name') {
if (!shouldAllowInlineColumnAiFallback(aiContext, editorSnapshot, inlineIntent.qualifier, inlineIntent.fragment)) {
return '';
}
}
}
const deterministicSyntaxCompletion = resolveDeterministicInlineSyntaxCompletion(editorSnapshot);
if (deterministicSyntaxCompletion.handled) {
return deterministicSyntaxCompletion.insertText;
}
const readiness = await resolveQueryEditorAiRuntimeReadiness(service, {
requireInlineCompletionModel: true,
});
const readiness = await resolveQueryEditorInlineRuntimeReadiness(service);
if (!readiness.ready || !readiness.provider) {
return '';
}

View File

@@ -6,8 +6,34 @@ import {
resolveOracleLikeExecutionSchemaName,
resolveOracleLikeLookupSchemaCandidates,
resolveQueryEditorNavigationTarget,
shouldHandleQueryEditorRunShortcutFallback,
} from './QueryEditorHelpers';
describe('QueryEditor run shortcut routing', () => {
it('reserves editor-originated shortcuts for Monaco and keeps document targets as a fallback', () => {
const editorTarget = {} as Node;
const editorPane = {
contains: (node: Node) => node === editorTarget,
} as Pick<Node, 'contains'>;
expect(shouldHandleQueryEditorRunShortcutFallback({
editorHasFocus: true,
targetNode: editorTarget,
editorPane,
})).toBe(false);
expect(shouldHandleQueryEditorRunShortcutFallback({
editorHasFocus: true,
targetNode: null,
editorPane,
})).toBe(true);
expect(shouldHandleQueryEditorRunShortcutFallback({
editorHasFocus: false,
targetNode: null,
editorPane,
})).toBe(false);
});
});
describe('QueryEditorHelpers Oracle-like execution schema', () => {
it('uses the selected schema when it differs from the login user', () => {
const config = {

View File

@@ -2283,6 +2283,24 @@ export const isDocumentLevelShortcutTarget = (targetNode: Node | null): boolean
return targetNode === document.body || targetNode === document.documentElement;
};
export const shouldHandleQueryEditorRunShortcutFallback = ({
editorHasFocus,
targetNode,
editorPane,
}: {
editorHasFocus: boolean;
targetNode: Node | null;
editorPane?: Pick<Node, 'contains'> | null;
}): boolean => {
if (!editorHasFocus) {
return false;
}
if (targetNode && editorPane?.contains(targetNode)) {
return false;
}
return isDocumentLevelShortcutTarget(targetNode);
};
export const clearQueryEditorLinkDecorations = (
editor: any,
decorationIdsRef: React.MutableRefObject<string[]>,