mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-14 08:53:04 +08:00
🐛 fix(query-editor): 修复快捷执行与 Windows 输入丢字
- 快捷执行仅由 Monaco 编辑器处理,保留 Shift+Home 反向选区和光标位置 - 缓冲并恢复 Windows 下丢失的可打印输入和延迟 IME 提交 - 降低 AI 行内补全预检与元数据刷新造成的编辑器输入抖动 - 补充快捷键、输入回退和 AI 补全回归测试
This commit is contained in:
197
frontend/src/components/MonacoEditor.input-fallback.test.tsx
Normal file
197
frontend/src/components/MonacoEditor.input-fallback.test.tsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[]>,
|
||||
|
||||
Reference in New Issue
Block a user