🐛 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

@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installPrintableInputFallback } from './MonacoEditor';
class FakeHTMLElement {
isConnected = true;
contains(target: unknown): boolean {
return target === this;
}
}
class FakeTextAreaElement extends FakeHTMLElement {
private listeners = new Map<string, Set<(event: InputEvent) => void>>();
addEventListener(type: string, listener: (event: InputEvent) => void): void {
const listeners = this.listeners.get(type) || new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: (event: InputEvent) => void): void {
this.listeners.get(type)?.delete(listener);
}
dispatchPrintableBeforeInput(text: string): void {
const event = {
data: text,
inputType: 'insertText',
isComposing: false,
defaultPrevented: false,
} as InputEvent;
this.listeners.get('beforeinput')?.forEach((listener) => listener(event));
}
}
describe('MonacoEditor printable input fallback', () => {
let editorDomNode: FakeHTMLElement & { querySelector: () => FakeTextAreaElement };
let input: FakeTextAreaElement;
let value: string;
let position: { lineNumber: number; column: number };
let modelContentListener: (() => void) | null;
let editor: any;
beforeEach(() => {
vi.useFakeTimers();
input = new FakeTextAreaElement();
editorDomNode = Object.assign(new FakeHTMLElement(), {
contains: (target: unknown) => target === editorDomNode || target === input,
querySelector: () => input,
});
vi.stubGlobal('HTMLElement', FakeHTMLElement);
vi.stubGlobal('HTMLTextAreaElement', FakeTextAreaElement);
vi.stubGlobal('document', { activeElement: input });
vi.stubGlobal('window', { setTimeout, clearTimeout });
value = '';
position = { lineNumber: 1, column: 1 };
modelContentListener = null;
const offsetAt = (target: { lineNumber: number; column: number }) => (
Math.max(0, Math.min(value.length, Number(target.column || 1) - 1))
);
const positionAt = (offset: number) => ({
lineNumber: 1,
column: Math.max(1, Math.min(value.length, offset) + 1),
});
editor = {
getDomNode: () => editorDomNode,
getSelection: () => ({
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column,
}),
getValue: () => value,
getModel: () => ({
getOffsetAt: offsetAt,
getPositionAt: positionAt,
}),
getPosition: () => position,
getOption: () => false,
trigger: vi.fn((_source: string, _command: string, payload: { text: string }) => {
value += payload.text;
position = { lineNumber: 1, column: position.column + payload.text.length };
}),
executeEdits: vi.fn((_source: string, edits: Array<{ range: any; text: string }>) => {
for (const edit of edits) {
const startOffset = offsetAt({
lineNumber: edit.range.startLineNumber,
column: edit.range.startColumn,
});
const endOffset = offsetAt({
lineNumber: edit.range.endLineNumber,
column: edit.range.endColumn,
});
value = value.slice(0, startOffset) + edit.text + value.slice(endOffset);
}
}),
setPosition: vi.fn((nextPosition: { lineNumber: number; column: number }) => {
position = nextPosition;
}),
onDidChangeModelContent: vi.fn((listener: () => void) => {
modelContentListener = listener;
return { dispose: vi.fn() };
}),
onDidDispose: vi.fn(),
};
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('coalesces consecutive missing printable input before recovering it once', () => {
installPrintableInputFallback(editor, {
editor: { EditorOption: { readOnly: 1 } },
});
input.dispatchPrintableBeforeInput('a');
vi.advanceTimersByTime(30);
input.dispatchPrintableBeforeInput('b');
vi.advanceTimersByTime(79);
expect(editor.trigger).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(editor.trigger).toHaveBeenCalledTimes(1);
expect(editor.trigger).toHaveBeenCalledWith(
'gonavi-printable-input-fallback',
'type',
{ text: 'ab' },
);
});
it('cancels recovery when Monaco receives the native model change', () => {
installPrintableInputFallback(editor, {
editor: { EditorOption: { readOnly: 1 } },
});
input.dispatchPrintableBeforeInput('a');
value = 'a';
position = { lineNumber: 1, column: 2 };
modelContentListener?.();
vi.advanceTimersByTime(200);
expect(editor.trigger).not.toHaveBeenCalled();
});
it('recovers a missing leading character when a later character is committed natively', () => {
installPrintableInputFallback(editor, {
editor: { EditorOption: { readOnly: 1 } },
});
input.dispatchPrintableBeforeInput('a');
input.dispatchPrintableBeforeInput('b');
value = 'b';
position = { lineNumber: 1, column: 2 };
modelContentListener?.();
vi.advanceTimersByTime(80);
expect(editor.trigger).not.toHaveBeenCalled();
expect(editor.executeEdits).toHaveBeenCalledWith(
'gonavi-printable-input-fallback',
[expect.objectContaining({ text: 'ab' })],
);
expect(value).toBe('ab');
expect(position).toEqual({ lineNumber: 1, column: 3 });
});
it('settles the previous missing input before buffering text after a cursor move', () => {
installPrintableInputFallback(editor, {
editor: { EditorOption: { readOnly: 1 } },
});
value = 'xy';
position = { lineNumber: 1, column: 2 };
input.dispatchPrintableBeforeInput('a');
position = { lineNumber: 1, column: 3 };
input.dispatchPrintableBeforeInput('b');
vi.advanceTimersByTime(80);
expect(editor.executeEdits).toHaveBeenCalledWith(
'gonavi-printable-input-fallback',
[expect.objectContaining({ text: 'a' })],
);
expect(editor.trigger).toHaveBeenCalledWith(
'gonavi-printable-input-fallback',
'type',
{ text: 'b' },
);
expect(value).toBe('xayb');
expect(position).toEqual({ lineNumber: 1, column: 5 });
});
});

View File

@@ -11,6 +11,7 @@ const DEFAULT_FONT_SIZE = 14;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 20;
const QUERY_EDITOR_AI_INLINE_CONTEXT_KEY = 'gonaviAiInlineSuggestionVisible';
const PRINTABLE_INPUT_FALLBACK_DELAY_MS = 80;
let monacoConfiguredPromise: Promise<void> | null = null;
let transparentThemesRegistered = false;
@@ -229,7 +230,7 @@ const patchQueryEditorAiInlineRightArrowFallback = (editor: any, monaco: any) =>
editor.addCommand = patchedAddCommand;
};
const installPrintableInputFallback = (editor: any, monaco: any) => {
export const installPrintableInputFallback = (editor: any, monaco: any) => {
const editorDomNode = editor?.getDomNode?.();
if (!editorDomNode || editor.__gonaviPrintableInputFallbackInstalled) {
return;
@@ -244,6 +245,143 @@ const installPrintableInputFallback = (editor: any, monaco: any) => {
configurable: true,
});
let pendingInput: {
valueBefore: string;
positionBefore: any;
offsetBefore: number;
text: string;
timer: number | null;
} | null = null;
const clearPendingInput = () => {
if (!pendingInput) {
return;
}
if (pendingInput.timer !== null) {
clearTimeout(pendingInput.timer);
}
pendingInput = null;
};
const getPendingNativeInputDelta = (pending: NonNullable<typeof pendingInput>) => {
const afterValue = String(editor.getValue?.() ?? '');
if (afterValue === pending.valueBefore) {
return null;
}
let startOffset = 0;
while (
startOffset < pending.valueBefore.length
&& startOffset < afterValue.length
&& pending.valueBefore[startOffset] === afterValue[startOffset]
) {
startOffset += 1;
}
let beforeEndOffset = pending.valueBefore.length;
let afterEndOffset = afterValue.length;
while (
beforeEndOffset > startOffset
&& afterEndOffset > startOffset
&& pending.valueBefore[beforeEndOffset - 1] === afterValue[afterEndOffset - 1]
) {
beforeEndOffset -= 1;
afterEndOffset -= 1;
}
if (startOffset !== pending.offsetBefore || beforeEndOffset !== startOffset) {
return null;
}
return {
insertedText: afterValue.slice(startOffset, afterEndOffset),
};
};
const isSubsequence = (candidate: string, source: string): boolean => {
let sourceIndex = 0;
for (const char of candidate) {
sourceIndex = source.indexOf(char, sourceIndex);
if (sourceIndex < 0) {
return false;
}
sourceIndex += char.length;
}
return true;
};
const hasNativeInputApplied = (pending: NonNullable<typeof pendingInput>): boolean => (
getPendingNativeInputDelta(pending)?.insertedText === pending.text
);
const isPendingInputContextCurrent = (
pending: NonNullable<typeof pendingInput>,
value: string,
position: any,
): boolean => {
if (value === pending.valueBefore) {
return sameEditorPosition(position, pending.positionBefore);
}
const nativeDelta = getPendingNativeInputDelta(pending);
if (!nativeDelta?.insertedText || !isSubsequence(nativeDelta.insertedText, pending.text)) {
return false;
}
const expectedPosition = editor.getModel?.()?.getPositionAt?.(
pending.offsetBefore + nativeDelta.insertedText.length,
);
return sameEditorPosition(position, expectedPosition);
};
const recoverPendingInputAtOriginalPosition = (
pending: NonNullable<typeof pendingInput>,
currentPosition: any,
): boolean => {
const nativeDelta = getPendingNativeInputDelta(pending);
const afterValue = String(editor.getValue?.() ?? '');
if (
(afterValue !== pending.valueBefore
&& (!nativeDelta?.insertedText || !isSubsequence(nativeDelta.insertedText, pending.text)))
|| typeof editor.executeEdits !== 'function'
) {
return false;
}
const model = editor.getModel?.();
const nativeText = nativeDelta?.insertedText || '';
const currentOffset = Number(model?.getOffsetAt?.(currentPosition));
const endPosition = model?.getPositionAt?.(
pending.offsetBefore + nativeText.length,
);
if (!endPosition || !Number.isFinite(currentOffset)) {
return false;
}
editor.executeEdits('gonavi-printable-input-fallback', [{
range: {
startLineNumber: pending.positionBefore.lineNumber,
startColumn: pending.positionBefore.column,
endLineNumber: endPosition.lineNumber,
endColumn: endPosition.column,
},
text: pending.text,
forceMoveMarkers: true,
}]);
const insertedLengthDelta = pending.text.length - nativeText.length;
const nextOffset = currentOffset <= pending.offsetBefore
? currentOffset
: currentOffset >= pending.offsetBefore + nativeText.length
? currentOffset + insertedLengthDelta
: pending.offsetBefore + Math.min(
currentOffset - pending.offsetBefore,
pending.text.length,
);
const nextPosition = model?.getPositionAt?.(nextOffset);
if (nextPosition) {
editor.setPosition?.(nextPosition);
}
return true;
};
const isReadOnly = (): boolean => {
try {
const optionId = monaco?.editor?.EditorOption?.readOnly;
@@ -270,10 +408,52 @@ const installPrintableInputFallback = (editor: any, monaco: any) => {
if (!isSelectionEmpty(selectionBefore)) {
return;
}
const beforeValue = String(editor.getValue?.() ?? '');
const beforePosition = editor.getPosition?.();
let beforeValue = String(editor.getValue?.() ?? '');
let beforePosition = editor.getPosition?.();
if (!beforePosition) {
return;
}
let beforeOffset = Number(editor.getModel?.()?.getOffsetAt?.(beforePosition));
if (!Number.isFinite(beforeOffset)) {
return;
}
if (pendingInput && hasNativeInputApplied(pendingInput)) {
clearPendingInput();
}
if (pendingInput && !isPendingInputContextCurrent(pendingInput, beforeValue, beforePosition)) {
recoverPendingInputAtOriginalPosition(pendingInput, beforePosition);
clearPendingInput();
beforeValue = String(editor.getValue?.() ?? '');
beforePosition = editor.getPosition?.();
if (!beforePosition) {
return;
}
beforeOffset = Number(editor.getModel?.()?.getOffsetAt?.(beforePosition));
if (!Number.isFinite(beforeOffset)) {
return;
}
}
if (pendingInput) {
pendingInput.text += text;
if (pendingInput.timer !== null) {
clearTimeout(pendingInput.timer);
}
} else {
pendingInput = {
valueBefore: beforeValue,
positionBefore: beforePosition,
offsetBefore: beforeOffset,
text,
timer: null,
};
}
window.setTimeout(() => {
const pending = pendingInput;
pending.timer = window.setTimeout(() => {
if (pendingInput !== pending) {
return;
}
pendingInput = null;
const domNode = editor.getDomNode?.();
if (!(domNode instanceof HTMLElement) || !domNode.isConnected || isReadOnly()) {
return;
@@ -283,15 +463,26 @@ const installPrintableInputFallback = (editor: any, monaco: any) => {
}
const afterValue = String(editor.getValue?.() ?? '');
const afterPosition = editor.getPosition?.();
if (afterValue !== beforeValue || !sameEditorPosition(beforePosition, afterPosition)) {
if (hasNativeInputApplied(pending)) {
return;
}
editor.trigger?.('gonavi-printable-input-fallback', 'type', { text });
}, 16);
if (afterValue !== pending.valueBefore || !sameEditorPosition(pending.positionBefore, afterPosition)) {
recoverPendingInputAtOriginalPosition(pending, afterPosition);
return;
}
editor.trigger?.('gonavi-printable-input-fallback', 'type', { text: pending.text });
}, PRINTABLE_INPUT_FALLBACK_DELAY_MS);
};
input.addEventListener('beforeinput', handleBeforeInput);
const modelContentDisposable = editor.onDidChangeModelContent?.(() => {
if (pendingInput && hasNativeInputApplied(pendingInput)) {
clearPendingInput();
}
});
editor.onDidDispose?.(() => {
clearPendingInput();
modelContentDisposable?.dispose?.();
input.removeEventListener('beforeinput', handleBeforeInput);
});
};

View File

@@ -12,6 +12,7 @@ import type { SavedQuery, TabData } from '../types';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
import { setGlobalImeCompositionActive } from '../utils/shortcuts';
import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
import { clearQueryEditorInlineRuntimeReadinessCache } from './queryEditor/QueryEditorAiAssist';
import QueryEditor, {
collectQueryEditorObjectDecorationCandidates,
resolveQueryEditorNavigationDecorations,
@@ -369,7 +370,7 @@ vi.mock('@monaco-editor/react', () => ({
const mountEditor = () => onMount?.(editorState.editor, {
editor: { setTheme: vi.fn() },
KeyMod: { CtrlCmd: 2048, WinCtrl: 256, Alt: 512, Shift: 1024 },
KeyCode: { KeyD: 68, KeyE: 69, KeyF: 70, KeyM: 77, KeyQ: 81, KeyS: 83, RightArrow: 39 },
KeyCode: { Enter: 13, KeyD: 68, KeyE: 69, KeyF: 70, KeyM: 77, KeyQ: 81, KeyS: 83, RightArrow: 39 },
languages: {
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
CompletionItemInsertTextRule: { InsertAsSnippet: 1 },
@@ -431,6 +432,14 @@ vi.mock('./DataGrid', () => ({
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
vi.mock('./resultDiff/ResultDiffWizard', () => ({
default: () => null,
}));
vi.mock('./resultDiff/ViewDataVerifyWizard', () => ({
default: () => null,
}));
vi.mock('./LogPanel', () => ({
default: ({
variant,
@@ -703,6 +712,7 @@ const createQueryEditorSplitNodeMock = (element: any) => {
describe('QueryEditor external SQL save', () => {
beforeEach(() => {
clearQueryEditorInlineRuntimeReadinessCache();
const completionState = (globalThis as any).__gonaviSqlCompletionState;
if (completionState) {
completionState.registered = false;
@@ -1898,7 +1908,7 @@ describe('QueryEditor external SQL save', () => {
editorState.modelContentListeners.forEach((listener) => listener({
changes: [{ text: 'T' }],
}));
vi.advanceTimersByTime(120);
vi.advanceTimersByTime(220);
for (let i = 0; i < 8; i += 1) {
await Promise.resolve();
}
@@ -6690,6 +6700,122 @@ describe('QueryEditor external SQL save', () => {
expect(editorState.editor.setValue).toHaveBeenCalledWith('SELECT 2;');
});
it('waits for the native IME commit before applying the composition fallback', async () => {
vi.useFakeTimers();
const domListeners: Record<string, ((event?: any) => void)[]> = {};
editorState.domNode.addEventListener.mockImplementation((type: string, listener: (event?: any) => void) => {
domListeners[type] ||= [];
domListeners[type].push(listener);
});
editorState.editor.getValue.mockReset();
editorState.editor.getValue.mockImplementation(() => editorState.value);
try {
await act(async () => {
create(<QueryEditor tab={createTab({ query: "select '';" })} />);
});
editorState.position = { lineNumber: 1, column: 9 };
editorState.selection = null;
editorState.editor.executeEdits.mockClear();
await act(async () => {
domListeners.compositionstart?.forEach((listener) => listener({ data: '' }));
domListeners.compositionend?.forEach((listener) => listener({ data: '我' }));
});
await act(async () => {
vi.advanceTimersByTime(79);
});
expect(editorState.editor.executeEdits).not.toHaveBeenCalledWith(
'gonavi-ime-composition-fallback',
expect.anything(),
);
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(editorState.editor.executeEdits).toHaveBeenCalledWith(
'gonavi-ime-composition-fallback',
expect.anything(),
);
} finally {
vi.useRealTimers();
}
});
it('skips inline AI metadata warmup when no inline model is configured', async () => {
vi.useFakeTimers();
try {
await act(async () => {
create(<QueryEditor tab={createTab({ query: 'select * from users' })} />);
});
backendApp.DBGetTables.mockClear();
editorState.position = { lineNumber: 1, column: 'select * from users'.length + 1 };
await act(async () => {
editorState.modelContentListeners.forEach((listener) => listener({
changes: [{ text: 's' }],
}));
vi.advanceTimersByTime(220);
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBGetTables).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('keeps deterministic inline SQL ghosts available before AI readiness succeeds', async () => {
vi.useFakeTimers();
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 1;
}),
cancelAnimationFrame: vi.fn(),
innerHeight: 900,
});
try {
await act(async () => {
create(<QueryEditor tab={createTab({ query: 'SELEC', dbName: 'main' })} />);
});
editorState.value = 'SELECT';
editorState.position = { lineNumber: 1, column: 'SELECT'.length + 1 };
editorState.domNode.appendChild.mockClear();
backendApp.DBGetTables.mockClear();
await act(async () => {
editorState.latestOnChange?.('SELECT');
editorState.modelContentListeners.forEach((listener) => listener({
changes: [{ text: 'T' }],
}));
vi.advanceTimersByTime(220);
await Promise.resolve();
});
const ghostOverlay = editorState.domNode.appendChild.mock.calls[
editorState.domNode.appendChild.mock.calls.length - 1
]?.[0];
expect(ghostOverlay?.textContent).toBe(' * FROM');
expect(backendApp.DBGetTables).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('recovers committed IME text when Monaco composition end leaves the model unchanged', async () => {
vi.useFakeTimers();
const domListeners: Record<string, ((event?: any) => void)[]> = {};
@@ -10605,6 +10731,74 @@ describe('QueryEditor external SQL save', () => {
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('keeps a reverse Shift+Home selection and caret when the Monaco run action executes it', async () => {
storeState.shortcutOptions.runQuery.mac = { enabled: true, combo: 'Meta+Enter' };
storeState.shortcutOptions.runQuery.windows = { enabled: true, combo: 'Ctrl+Enter' };
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn((event: Event) => {
windowListeners[event.type]?.forEach((listener) => listener(event));
return true;
}),
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 1;
}),
cancelAnimationFrame: vi.fn(),
innerHeight: 900,
});
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['selected'], rows: [{ selected: 2 }] }],
});
await act(async () => {
create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as selected;\nselect 3;',
})} />);
});
const reverseSelection = {
selectionStartLineNumber: 2,
selectionStartColumn: 'select 2 as selected'.length + 1,
positionLineNumber: 2,
positionColumn: 1,
startLineNumber: 2,
startColumn: 1,
endLineNumber: 2,
endColumn: 'select 2 as selected'.length + 1,
};
editorState.position = { lineNumber: 2, column: 1 };
editorState.selection = reverseSelection;
editorState.editor.setPosition.mockClear();
editorState.editor.setSelection.mockClear();
const runAction = findEditorAction('gonavi.runQuery');
expect(runAction).toBeTruthy();
await act(async () => {
runAction.run();
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(
expect.anything(),
'main',
expect.stringContaining('select 2 as selected'),
'query-1',
);
expect(editorState.position).toEqual({ lineNumber: 2, column: 1 });
expect(editorState.selection).toEqual(reverseSelection);
expect(editorState.editor.setPosition).not.toHaveBeenCalled();
expect(editorState.editor.setSelection).not.toHaveBeenCalled();
});
it('allows editable table columns while leaving expression columns out of commits', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,

View File

@@ -159,6 +159,7 @@ import {
splitCompletionSchemaAndTable,
splitQueryIdentifierPathSegments,
stripCompletionIdentifierQuotes,
shouldHandleQueryEditorRunShortcutFallback,
} from './queryEditor/QueryEditorHelpers';
import {
buildQueryEditorAiInlineSuggestOptions,
@@ -168,6 +169,8 @@ import {
resolveInlineSqlGhostPreviewText,
resolveQueryEditorInlineMemoryInsertText,
resolveQueryEditorInlineCompletionIntentDetails,
resolveQueryEditorInlineLocalCompletion,
resolveQueryEditorInlineRuntimeReadiness,
shouldTriggerQueryEditorInlineObjectSuggestFallback,
shouldRequestQueryEditorInlineCompletion,
type QueryEditorAiApplyMode,
@@ -189,8 +192,9 @@ const QUERY_EDITOR_MONACO_FIND_OPTIONS = {
const QUERY_EDITOR_NATIVE_SELECT_CURRENT_LINE_EVENT = 'gonavi:native-select-current-line';
const QUERY_EDITOR_MAC_FIND_WITH_SELECTION_COMBO = 'Meta+E';
const QUERY_EDITOR_MAC_FIND_WITH_SELECTION_GUARD_ACTION_ID = 'gonavi.suppressMacFindWithSelection';
const QUERY_EDITOR_AI_INLINE_DEBOUNCE_MS = 90;
const QUERY_EDITOR_AI_INLINE_DEBOUNCE_MS = 220;
const QUERY_EDITOR_AI_INLINE_CONTEXT_KEY = 'gonaviAiInlineSuggestionVisible';
const QUERY_EDITOR_IME_FALLBACK_DELAY_MS = 80;
const normalizeQueryEditorInlineMemorySqlKey = (sql: string): string => (
String(sql || '')
@@ -3775,9 +3779,36 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return;
}
try {
const aiContext = buildQueryEditorAiContext();
const localCompletion = resolveQueryEditorInlineLocalCompletion({
aiContext,
editorSnapshot,
deferEmptySchemaCompletion: true,
});
if (localCompletion.handled) {
if (localCompletion.insertText.trim()) {
renderAiInlineGhost(model, position, localCompletion.insertText, editorSnapshot);
}
return;
}
const aiService = getQueryEditorAiService();
const readiness = await resolveQueryEditorInlineRuntimeReadiness(aiService);
if (
!readiness.ready
|| requestId !== aiInlineGhostRequestSeqRef.current
|| editorRef.current !== editor
) {
return;
}
await ensureQueryEditorAiContextMetadata(editorSnapshot);
if (
requestId !== aiInlineGhostRequestSeqRef.current
|| editorRef.current !== editor
) {
return;
}
const insertText = await requestQueryEditorInlineCompletion({
service: getQueryEditorAiService(),
service: aiService,
aiContext: buildQueryEditorAiContext(),
editorSnapshot,
});
@@ -4061,7 +4092,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (nextPosition) {
editor.setPosition?.(nextPosition);
}
}, 0);
}, QUERY_EDITOR_IME_FALLBACK_DELAY_MS);
};
const handleEditorDragOver = (rawEvent: Event) => {
const event = rawEvent as DragEvent;
@@ -4223,6 +4254,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (recoverTriggerSqlAiCompletionFallback(event)) {
return;
}
if (imeCompositionFallbackTimerRef.current !== null) {
clearImeCompositionFallbackTimer();
syncQueryDraft(getEditorText());
}
const hasSlashCommandMarker = Array.isArray(event?.changes)
&& event.changes.some((change: any) => /__AI_\w+__/.test(String(change?.text || '')));
if (hasSlashCommandMarker) {
@@ -4458,6 +4493,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
id: 'gonavi.runQuery',
label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.runQuery.label'),
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
keybindingContext: 'editorTextFocus',
run: () => {
window.dispatchEvent(new CustomEvent('gonavi:run-active-query', {
detail: { requireSelection: true },
@@ -6730,11 +6766,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return;
}
const editorHasFocus = !!editorRef.current?.hasTextFocus?.();
if (!editorHasFocus && !isEditableElement(event.target)) {
const targetNode = resolveEventTargetNode(event.target);
if (!shouldHandleQueryEditorRunShortcutFallback({
editorHasFocus,
targetNode,
editorPane: editorPaneRef.current,
})) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation?.();
void handleRunSelectedShortcut();
};
@@ -6851,6 +6893,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
id: 'gonavi.runQuery',
label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.runQuery.label'),
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
keybindingContext: 'editorTextFocus',
run: () => {
window.dispatchEvent(new CustomEvent('gonavi:run-active-query', {
detail: { requireSelection: true },

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[]>,