diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index efcac9de..92cb5bd5 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -10042,7 +10042,7 @@ describe('QueryEditor external SQL save', () => { }); }); - it('shows "No executable SQL." in English when the cursor is on a blank line', async () => { + it('executes all SQL when the cursor is on a trailing blank line', async () => { storeState.languagePreference = 'en-US'; setCurrentLanguage('en-US'); backendApp.DBQueryMulti.mockResolvedValueOnce({ @@ -10054,7 +10054,7 @@ describe('QueryEditor external SQL save', () => { await act(async () => { renderer = create(); }); @@ -10081,18 +10081,22 @@ describe('QueryEditor external SQL save', () => { expect(textContent(renderer!.toJSON())).toContain('Result 1'); backendApp.DBQueryMulti.mockClear(); messageApi.info.mockClear(); + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['a'], rows: [{ a: 1 }] }], + }); - editorState.position = { lineNumber: 3, column: 1 }; + editorState.position = { lineNumber: 4, column: 1 }; editorState.selection = { - startLineNumber: 3, + startLineNumber: 4, startColumn: 1, - endLineNumber: 3, + endLineNumber: 4, endColumn: 1, - positionLineNumber: 3, + positionLineNumber: 4, positionColumn: 1, }; editorState.cursorPositionListeners.forEach((listener) => { - listener({ position: { lineNumber: 3, column: 1 } }); + listener({ position: { lineNumber: 4, column: 1 } }); }); await act(async () => { @@ -10105,8 +10109,12 @@ describe('QueryEditor external SQL save', () => { await Promise.resolve(); }); - expect(backendApp.DBQueryMulti).not.toHaveBeenCalled(); - expect(messageApi.info).toHaveBeenCalledWith('No executable SQL.'); + expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1); + const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]); + expect(executedSql).toContain('select 1 as a'); + expect(executedSql).toContain('select 2 as b'); + expect(executedSql).toContain('select 3 as c'); + expect(messageApi.info).not.toHaveBeenCalledWith('No executable SQL.'); expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。'); expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })])); }); diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index d49fa14d..7a95a1c3 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -15,7 +15,7 @@ import QueryEditor, { resolveQueryEditorNavigationDecorations, resolveQueryEditorNavigationTarget, } from './QueryEditor'; -import QueryEditorResultsPanel from './QueryEditorResultsPanel'; +import QueryEditorResultsPanel, { shouldActivateResultTabDetachPointer } from './QueryEditorResultsPanel'; const storeState = vi.hoisted(() => ({ connections: [ @@ -590,6 +590,99 @@ const createDefaultConnections = () => ([ ]); describe('QueryEditor external SQL save', () => { + it('does not start result-tab detaching from close icons or portal menu items', () => { + const tabContent = { + closest: vi.fn(() => null), + } as unknown as EventTarget; + const closeIconSvg = { + closest: vi.fn((selector: string) => + selector.includes('.query-result-tab-close') ? { className: 'query-result-tab-close' } : null), + } as unknown as EventTarget; + const contextMenuItem = { + closest: vi.fn((selector: string) => + selector.includes('[role="menuitem"]') ? { role: 'menuitem' } : null), + } as unknown as EventTarget; + + expect(shouldActivateResultTabDetachPointer({ button: 0, target: tabContent })).toBe(true); + expect(shouldActivateResultTabDetachPointer({ button: 0, target: closeIconSvg })).toBe(false); + expect(shouldActivateResultTabDetachPointer({ button: 0, target: contextMenuItem })).toBe(false); + expect(shouldActivateResultTabDetachPointer({ button: 2, target: tabContent })).toBe(false); + }); + + it('closes the active result tab without capturing a close-icon pointer', async () => { + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + const onCloseResult = vi.fn(); + let renderer!: ReactTestRenderer; + + await act(async () => { + renderer = create( + , + ); + }); + + const resultTabLabel = renderer.root.findAll((node) => + typeof node.props?.onPointerDown === 'function' + && String(node.props?.className || '').split(/\s+/).includes('query-result-tab-label'), + )[0]; + const closeButton = findByClassName(renderer, 'query-result-tab-close'); + const closeIconSvg = { + closest: vi.fn((selector: string) => + selector.includes('.query-result-tab-close') ? { className: 'query-result-tab-close' } : null), + } as unknown as EventTarget; + const setPointerCapture = vi.fn(); + + resultTabLabel.props.onPointerDown({ + button: 0, + isPrimary: true, + target: closeIconSvg, + currentTarget: { setPointerCapture }, + }); + expect(setPointerCapture).not.toHaveBeenCalled(); + + const pointerStopPropagation = vi.fn(); + closeButton.props.onPointerDown({ stopPropagation: pointerStopPropagation }); + expect(pointerStopPropagation).toHaveBeenCalledOnce(); + + closeButton.props.onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + expect(onCloseResult).toHaveBeenCalledWith('result-1'); + await act(async () => { + renderer.unmount(); + }); + }); + beforeEach(() => { const completionState = (globalThis as any).__gonaviSqlCompletionState; if (completionState) { @@ -2125,7 +2218,7 @@ describe('QueryEditor external SQL save', () => { expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。'); }); - it('shows "No executable SQL." in English when the cursor is on a blank line', async () => { + it('executes all SQL when the cursor is on a blank line', async () => { storeState.languagePreference = 'en-US'; setCurrentLanguage('en-US'); backendApp.DBQueryMulti.mockResolvedValueOnce({ @@ -2164,6 +2257,10 @@ describe('QueryEditor external SQL save', () => { expect(textContent(renderer!.toJSON())).toContain('Result 1'); backendApp.DBQueryMulti.mockClear(); messageApi.info.mockClear(); + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['a'], rows: [{ a: 1 }] }], + }); editorState.position = { lineNumber: 3, column: 1 }; editorState.selection = { @@ -2188,8 +2285,12 @@ describe('QueryEditor external SQL save', () => { await Promise.resolve(); }); - expect(backendApp.DBQueryMulti).not.toHaveBeenCalled(); - expect(messageApi.info).toHaveBeenCalledWith('No executable SQL.'); + expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1); + const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]); + expect(executedSql).toContain('select 1 as a'); + expect(executedSql).toContain('select 2 as b'); + expect(executedSql).toContain('select 3 as c'); + expect(messageApi.info).not.toHaveBeenCalledWith('No executable SQL.'); expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。'); expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })])); }); @@ -2816,6 +2917,54 @@ describe('QueryEditor external SQL save', () => { })).toHaveLength(0); }); + it('closes the active result tab directly without switching to the log tab', async () => { + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [ + { columns: ['a'], rows: [{ a: 1 }] }, + { columns: ['b'], rows: [{ b: 2 }] }, + ], + }); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + const runButton = findButton(renderer, '运行'); + runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); + await runButton.props.onClick(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const resultTabs = renderer.root.findAll((node) => + node.type === 'button' && String(node.props?.['data-tab-key'] || '').startsWith('result-'), + ); + expect(resultTabs).toHaveLength(2); + + await act(async () => { + resultTabs[1].props.onClick(); + }); + expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })])); + + const closeButtons = renderer.root.findAll((node) => + String(node.props?.className || '').split(/\s+/).includes('query-result-tab-close'), + ); + await act(async () => { + closeButtons[1].props.onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + }); + + expect(renderer.root.findAll((node) => + String(node.props?.className || '').split(/\s+/).includes('query-result-tab-label'), + )).toHaveLength(1); + expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })])); + }); + it('replaces the current result when rerunning the same cursor SQL', async () => { backendApp.DBQueryMulti .mockResolvedValueOnce({ diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx index 4f7aa543..25f6964e 100644 --- a/frontend/src/components/QueryEditorResultsPanel.tsx +++ b/frontend/src/components/QueryEditorResultsPanel.tsx @@ -90,6 +90,30 @@ const resolveVisibleQueryResultColumns = (columns: string[], globalHiddenColumns return visibleColumns.length > 0 || columns.length === 0 ? visibleColumns : columns; }; +const RESULT_TAB_DETACH_INTERACTIVE_SELECTOR = [ + '.query-result-tab-close', + 'button', + 'a', + 'input', + 'textarea', + 'select', + '[contenteditable="true"]', + '[role="button"]', + '[role="menuitem"]', + '.ant-dropdown-menu', +].join(', '); + +export const shouldActivateResultTabDetachPointer = (event: { + button: number; + isPrimary?: boolean; + target: EventTarget | null; +}): boolean => { + if (event.button !== 0 || event.isPrimary === false) return false; + const target = event.target as { closest?: (selector: string) => Element | null } | null; + return typeof target?.closest !== 'function' + || target.closest(RESULT_TAB_DETACH_INTERACTIVE_SELECTOR) === null; +}; + const QueryEditorResultsPanel: React.FC = ({ resultSets, activeResultKey, @@ -145,11 +169,7 @@ const QueryEditorResultsPanel: React.FC = ({ }, [resultSets, t]); const handleResultTabPointerDown = useCallback((event: React.PointerEvent, key: string) => { - if (!onOpenResultInWindow || event.button !== 0) return; - const target = event.target instanceof HTMLElement ? event.target : null; - if (target?.closest('.query-result-tab-close, button, a, input, textarea')) { - return; - } + if (!onOpenResultInWindow || !shouldActivateResultTabDetachPointer(event)) return; const title = resolveResultTabTitle(key); resultTabDragRef.current = { key, @@ -435,6 +455,7 @@ const QueryEditorResultsPanel: React.FC = ({ event.stopPropagation()} onClick={(event) => { event.preventDefault(); event.stopPropagation(); diff --git a/frontend/src/utils/sqlStatementSelection.test.ts b/frontend/src/utils/sqlStatementSelection.test.ts index b38a361f..520a5c39 100644 --- a/frontend/src/utils/sqlStatementSelection.test.ts +++ b/frontend/src/utils/sqlStatementSelection.test.ts @@ -550,19 +550,30 @@ describe('sqlStatementSelection', () => { }); }); - it('falls back to the current line when the cursor is not inside a statement', () => { + it('falls back to all SQL when the cursor is on a blank line between statements', () => { const sql = 'select 1;\n\n select 2'; - expect(resolveExecutableSql(sql, sql.indexOf('\n\n') + 1)).toBeNull(); + expect(resolveExecutableSql(sql, sql.indexOf('\n\n') + 1)).toEqual({ + sql, + source: 'all', + }); expect(resolveExecutableSql(sql, sql.indexOf(' select 2'))).toEqual({ sql: 'select 2', source: 'statement', }); }); - it('does not jump to the next statement when executing from blank space', () => { - const sql = 'select 1;\n\nselect 2;'; + it('falls back to all SQL when the cursor is on a trailing blank line', () => { + const sql = 'select 1;\nselect 2;\n'; - expect(resolveExecutableSql(sql, sql.indexOf('\n\n') + 1)).toBeNull(); + expect(resolveExecutableSql(sql, sql.length)).toEqual({ + sql, + source: 'all', + }); + }); + + it('returns null for empty or comment-only SQL', () => { + expect(resolveExecutableSql(' \n\t ', 0)).toBeNull(); + expect(resolveExecutableSql('-- nothing to execute\n\n/* still nothing */', 3)).toBeNull(); }); }); diff --git a/frontend/src/utils/sqlStatementSelection.ts b/frontend/src/utils/sqlStatementSelection.ts index bc92f8bf..c638f271 100644 --- a/frontend/src/utils/sqlStatementSelection.ts +++ b/frontend/src/utils/sqlStatementSelection.ts @@ -4,7 +4,7 @@ export interface SqlStatementRange { text: string; } -export type SqlExecutionSelectionSource = 'selection' | 'statement' | 'line'; +export type SqlExecutionSelectionSource = 'selection' | 'statement' | 'line' | 'all'; export interface SqlExecutionSelection { sql: string; @@ -519,6 +519,9 @@ export const resolveExecutableSql = ( const text = String(sql || '').replace(/\r\n/g, '\n'); const offset = Math.max(0, Math.min(text.length, Number.isFinite(cursorOffset) ? cursorOffset : 0)); const ranges = findSqlStatementRanges(text, dbType); + if (ranges.length === 0) { + return null; + } const statement = ranges.find((range) => offset >= range.start && offset <= range.end); if (statement?.text.trim()) { return { sql: statement.text, source: 'statement' }; @@ -546,5 +549,5 @@ export const resolveExecutableSql = ( return { sql: line, source: 'line' }; } - return null; + return { sql: String(sql || ''), source: 'all' }; };