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' };
};