🐛 fix(query-editor): 修复连续按 Ctrl/Cmd 时对象跳转失效

This commit is contained in:
Syngnat
2026-05-30 22:52:53 +08:00
parent ee96125385
commit b516acb173
2 changed files with 107 additions and 5 deletions

View File

@@ -81,6 +81,7 @@ const editorState = vi.hoisted(() => {
const state = {
value: '',
editor: null as any,
domNode: { style: { cursor: '' } },
position: { lineNumber: 1, column: 1 },
selection: null as any,
providers: [] as any[],
@@ -140,6 +141,7 @@ const editorState = vi.hoisted(() => {
state.position = position;
}),
getSelection: vi.fn(() => state.selection),
getDomNode: vi.fn(() => state.domNode),
setSelection: vi.fn((selection: any) => {
state.selection = selection;
}),
@@ -348,6 +350,7 @@ describe('QueryEditor external SQL save', () => {
editorState.value = '';
editorState.position = { lineNumber: 1, column: 1 };
editorState.selection = null;
editorState.domNode.style.cursor = '';
editorState.providers = [];
editorState.cursorPositionListeners = [];
editorState.mouseMoveListeners = [];
@@ -525,16 +528,83 @@ describe('QueryEditor external SQL save', () => {
});
expect(editorState.editor.deltaDecorations).toHaveBeenCalled();
expect(editorState.editor.updateOptions).toHaveBeenCalledWith({ mouseStyle: 'pointer' });
expect(editorState.domNode.style.cursor).toBe('pointer');
const lastDecorationCall = editorState.editor.deltaDecorations.mock.calls.at(-1);
expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint');
await act(async () => {
editorState.mouseLeaveListeners[0]?.();
});
expect(editorState.domNode.style.cursor).toBe('');
expect(editorState.editor.updateOptions).toHaveBeenLastCalledWith({ mouseStyle: 'text' });
});
it('keeps hover underline active when ctrl/cmd is pressed repeatedly without moving the mouse', async () => {
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(),
});
editorState.value = 'select * from analytics.events where id = 1';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] });
backendApp.DBGetTables
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] })
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] });
backendApp.DBGetAllColumns
.mockResolvedValueOnce({ success: true, data: [] })
.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
editorState.mouseMoveListeners[0]?.({
target: { position: { lineNumber: 1, column: 27 } },
event: {
ctrlKey: true,
metaKey: false,
},
});
});
const firstDecorationCallCount = editorState.editor.deltaDecorations.mock.calls.length;
expect(firstDecorationCallCount).toBeGreaterThan(0);
expect(editorState.domNode.style.cursor).toBe('pointer');
await act(async () => {
const repeatedCtrlEvent = {
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
key: 'Control',
code: 'ControlLeft',
repeat: true,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: null,
};
windowListeners.keydown?.forEach((listener) => listener(repeatedCtrlEvent));
windowListeners.keydown?.forEach((listener) => listener(repeatedCtrlEvent));
});
expect(editorState.editor.deltaDecorations.mock.calls.length).toBeGreaterThan(firstDecorationCallCount);
expect(editorState.domNode.style.cursor).toBe('pointer');
const lastDecorationCall = editorState.editor.deltaDecorations.mock.calls.at(-1);
expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint');
});
it('opens a view tab on ctrl left click inside the editor', async () => {
editorState.value = 'select * from reporting.active_users';
autoFetchState.visible = true;

View File

@@ -1412,6 +1412,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const selectCurrentStatementActionRef = useRef<any>(null);
const lastExternalQueryRef = useRef<string>(getTabQueryValue(tab));
const lastEditorCursorPositionRef = useRef<any>(null);
const lastHoverTargetPositionRef = useRef<{ lineNumber: number; column: number } | null>(null);
const lastExecutedEditorQueryRef = useRef<string>('');
const linkDecorationIdsRef = useRef<string[]>([]);
const ctrlMetaPressedRef = useRef(false);
@@ -1868,16 +1869,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
monacoRef.current = monaco;
lastEditorCursorPositionRef.current = normalizeEditorPosition(editor.getPosition?.());
const applyNavigationHoverState = (event: any) => {
const applyNavigationHoverStateAtPosition = (targetPosition: { lineNumber: number; column: number } | null) => {
if (!ctrlMetaPressedRef.current) {
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
return;
}
const targetPosition = normalizeEditorPosition(event?.target?.position);
if (!targetPosition) {
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
return;
}
const model = editor.getModel?.();
@@ -1896,6 +1898,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (decorations.length === 0) {
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
return;
}
@@ -1914,20 +1917,36 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
},
})),
);
editor.updateOptions?.({ mouseStyle: 'pointer' });
setQueryEditorMouseCursor(editor, 'pointer');
};
const applyNavigationHoverState = (event: any) => {
const targetPosition = normalizeEditorPosition(event?.target?.position);
lastHoverTargetPositionRef.current = targetPosition;
applyNavigationHoverStateAtPosition(targetPosition);
};
const syncModifierState = (keyboardEvent?: KeyboardEvent | MouseEvent | null) => {
const wasPressed = ctrlMetaPressedRef.current;
ctrlMetaPressedRef.current = !!(keyboardEvent?.ctrlKey || keyboardEvent?.metaKey);
if (!ctrlMetaPressedRef.current) {
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
return;
}
const isKeyboardLikeEvent = keyboardEvent
&& typeof keyboardEvent === 'object'
&& ('key' in keyboardEvent || 'code' in keyboardEvent || 'repeat' in keyboardEvent);
if (!wasPressed || isKeyboardLikeEvent) {
applyNavigationHoverStateAtPosition(lastHoverTargetPositionRef.current);
}
};
const handleWindowBlur = () => {
ctrlMetaPressedRef.current = false;
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
};
// 应用透明主题(主题由 MonacoEditor 包装组件按需注册)
@@ -1945,8 +1964,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
applyNavigationHoverState(event);
});
editor.onMouseLeave?.(() => {
lastHoverTargetPositionRef.current = null;
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
});
window.addEventListener('keydown', syncModifierState);
@@ -2100,6 +2121,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
editor.onDidDispose?.(() => {
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
setQueryEditorMouseCursor(editor, '');
window.removeEventListener('keydown', syncModifierState);
window.removeEventListener('keyup', syncModifierState);
window.removeEventListener('blur', handleWindowBlur);
@@ -4066,7 +4088,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
</Form>
</Modal>
</div>
);
);
};
const setQueryEditorMouseCursor = (
editor: any,
cursor: '' | 'pointer',
) => {
const domNode = editor?.getDomNode?.();
if (domNode?.style) {
domNode.style.cursor = cursor;
}
};
export default React.memo(QueryEditor);