From b516acb173b8942970d3d05960bac7abc6187a6f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 30 May 2026 22:52:53 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=BF=9E=E7=BB=AD=E6=8C=89=20Ctrl/Cmd=20=E6=97=B6?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E8=B7=B3=E8=BD=AC=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../QueryEditor.external-sql-save.test.tsx | 72 ++++++++++++++++++- frontend/src/components/QueryEditor.tsx | 40 +++++++++-- 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index b92e93c..614aa35 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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 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(); + }); + 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; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index d1ede81..5b40284 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1412,6 +1412,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const selectCurrentStatementActionRef = useRef(null); const lastExternalQueryRef = useRef(getTabQueryValue(tab)); const lastEditorCursorPositionRef = useRef(null); + const lastHoverTargetPositionRef = useRef<{ lineNumber: number; column: number } | null>(null); const lastExecutedEditorQueryRef = useRef(''); const linkDecorationIdsRef = useRef([]); 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 - ); + ); +}; + +const setQueryEditorMouseCursor = ( + editor: any, + cursor: '' | 'pointer', +) => { + const domNode = editor?.getDomNode?.(); + if (domNode?.style) { + domNode.style.cursor = cursor; + } }; export default React.memo(QueryEditor);