🐛 fix(query-editor): 修复 SQL 执行与结果标签交互

- 空白光标回退执行编辑器内全部有效 SQL
- 修复活动结果标签必须切换日志后才能关闭的问题
- 阻止关闭按钮和菜单指针误触结果标签分离拖拽
- 补充空白行、尾部空白和结果标签关闭回归
This commit is contained in:
Syngnat
2026-07-20 13:40:03 +08:00
parent 63ffa12958
commit 02eec5aee3
5 changed files with 217 additions and 25 deletions

View File

@@ -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(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\n\nselect 3 as c;',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;\n',
})} />);
});
@@ -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 })]));
});

View File

@@ -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(
<QueryEditorResultsPanel
resultSets={[{
key: 'result-1',
sql: 'select 1',
rows: [{ value: 1 }],
columns: ['value'],
pkColumns: [],
readOnly: true,
}]}
activeResultKey="result-1"
loading={false}
executionError=""
sqlLogCount={1}
darkMode={false}
isV2Ui
currentDb="main"
currentConnectionId="conn-1"
toggleShortcutLabel=""
onActiveResultKeyChange={vi.fn()}
onHide={vi.fn()}
onCloseResult={onCloseResult}
onCloseOtherResultTabs={vi.fn()}
onCloseResultTabsToLeft={vi.fn()}
onCloseResultTabsToRight={vi.fn()}
onCloseAllResultTabs={vi.fn()}
onOpenResultInWindow={vi.fn()}
onReloadResult={vi.fn()}
onResultPageChange={vi.fn()}
onResultSort={vi.fn()}
onDiagnoseExecutionError={vi.fn()}
/>,
);
});
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(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;',
})} />);
});
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({

View File

@@ -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<QueryEditorResultsPanelProps> = ({
resultSets,
activeResultKey,
@@ -145,11 +169,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
}, [resultSets, t]);
const handleResultTabPointerDown = useCallback((event: React.PointerEvent<HTMLElement>, 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<QueryEditorResultsPanelProps> = ({
<Tooltip title={t('query_editor.result.close')}>
<span
className="query-result-tab-close"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();

View File

@@ -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();
});
});

View File

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