mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 00:33:28 +08:00
🐛 fix(query-editor): 修复 SQL 执行与结果标签交互
- 空白光标回退执行编辑器内全部有效 SQL - 修复活动结果标签必须切换日志后才能关闭的问题 - 阻止关闭按钮和菜单指针误触结果标签分离拖拽 - 补充空白行、尾部空白和结果标签关闭回归
This commit is contained in:
@@ -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 })]));
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user