diff --git a/frontend/src/components/MonacoEditor.input-fallback.test.tsx b/frontend/src/components/MonacoEditor.input-fallback.test.tsx new file mode 100644 index 00000000..339f976d --- /dev/null +++ b/frontend/src/components/MonacoEditor.input-fallback.test.tsx @@ -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 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 }); + }); +}); diff --git a/frontend/src/components/MonacoEditor.tsx b/frontend/src/components/MonacoEditor.tsx index bad1b679..6d0fdf53 100644 --- a/frontend/src/components/MonacoEditor.tsx +++ b/frontend/src/components/MonacoEditor.tsx @@ -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 | 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) => { + 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): boolean => ( + getPendingNativeInputDelta(pending)?.insertedText === pending.text + ); + + const isPendingInputContextCurrent = ( + pending: NonNullable, + 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, + 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); }); }; diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 6a44101c..5751d8fb 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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 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(); + }); + + 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(); + }); + + 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 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(); + }); + + 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 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 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(); + }); + + 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, diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 7daa58c1..80c7b32c 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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 }, diff --git a/frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts b/frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts index 20d3c9b9..821fbf65 100644 --- a/frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts +++ b/frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts @@ -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 () => { diff --git a/frontend/src/components/queryEditor/QueryEditorAiAssist.ts b/frontend/src/components/queryEditor/QueryEditorAiAssist.ts index eb59e5c7..e08953ca 100644 --- a/frontend/src/components/queryEditor/QueryEditorAiAssist.ts +++ b/frontend/src/components/queryEditor/QueryEditorAiAssist.ts @@ -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; +}; + +let inlineRuntimeReadinessCache = new WeakMap(); + +export const clearQueryEditorInlineRuntimeReadinessCache = (): void => { + inlineRuntimeReadinessCache = new WeakMap(); +}; + +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 => { + 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 => { - 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 ''; } diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts index a0ddd196..502bf044 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts @@ -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; + + 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 = { diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.ts index 9d65511a..1301a504 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.ts @@ -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 | null; +}): boolean => { + if (!editorHasFocus) { + return false; + } + if (targetNode && editorPane?.contains(targetNode)) { + return false; + } + return isDocumentLevelShortcutTarget(targetNode); +}; + export const clearQueryEditorLinkDecorations = ( editor: any, decorationIdsRef: React.MutableRefObject,