mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-15 02:49:49 +08:00
✨ feat(query-editor): 支持结果区默认隐藏和快捷键切换
- 默认隐藏 SQL 结果区,执行成功或失败后自动展开 - 增加结果区显示/隐藏按钮和 Win/Mac 快捷键提示 - 在结果表工具栏提供隐藏入口并补充前端回归测试
This commit is contained in:
@@ -1226,6 +1226,7 @@ interface DataGridProps {
|
||||
onApplyQuickWhereCondition?: (condition: string) => void;
|
||||
scrollSnapshot?: { top: number; left: number };
|
||||
onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void;
|
||||
toolbarExtraActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
type GridFilterCondition = FilterCondition & {
|
||||
@@ -1487,7 +1488,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
data, columnNames, loading, tableName, objectType = 'table', exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
|
||||
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
|
||||
onApplyQuickWhereCondition,
|
||||
scrollSnapshot, onScrollSnapshotChange
|
||||
scrollSnapshot, onScrollSnapshotChange, toolbarExtraActions
|
||||
}) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
@@ -7432,6 +7433,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
aiShortcutLabel={aiShortcutLabel}
|
||||
legacyAiButtonStyle={legacyAiButtonStyle}
|
||||
paginationTotalCountLoading={pagination?.totalCountLoading}
|
||||
toolbarExtraActions={toolbarExtraActions}
|
||||
filterConditions={filterConditions}
|
||||
sortInfo={sortInfo}
|
||||
displayColumnNames={displayColumnNames}
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface DataGridToolbarFrameProps {
|
||||
aiShortcutLabel: string;
|
||||
legacyAiButtonStyle?: React.CSSProperties;
|
||||
paginationTotalCountLoading?: boolean;
|
||||
toolbarExtraActions?: React.ReactNode;
|
||||
filterConditions: GridFilterCondition[];
|
||||
sortInfo: GridSortInfo[];
|
||||
displayColumnNames: string[];
|
||||
@@ -166,6 +167,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
aiShortcutLabel,
|
||||
legacyAiButtonStyle,
|
||||
paginationTotalCountLoading,
|
||||
toolbarExtraActions,
|
||||
filterConditions,
|
||||
sortInfo,
|
||||
displayColumnNames,
|
||||
@@ -410,6 +412,13 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
{toolbarExtraActions && (
|
||||
<>
|
||||
{renderToolbarDivider()}
|
||||
{toolbarExtraActions}
|
||||
</>
|
||||
)}
|
||||
|
||||
{prefersManualTotalCount && (
|
||||
<>
|
||||
{renderToolbarDivider()}
|
||||
|
||||
@@ -36,7 +36,12 @@ const storeState = vi.hoisted(() => ({
|
||||
appearance: { uiVersion: 'legacy' as 'legacy' | 'v2' },
|
||||
sqlFormatOptions: { keywordCase: 'upper' as const },
|
||||
setSqlFormatOptions: vi.fn(),
|
||||
queryOptions: { maxRows: 5000 },
|
||||
queryOptions: {
|
||||
maxRows: 5000,
|
||||
showColumnComment: true,
|
||||
showColumnType: true,
|
||||
showQueryResultsPanel: false,
|
||||
},
|
||||
setQueryOptions: vi.fn(),
|
||||
shortcutOptions: {
|
||||
runQuery: {
|
||||
@@ -51,6 +56,10 @@ const storeState = vi.hoisted(() => ({
|
||||
mac: { enabled: true, combo: 'Meta+S' },
|
||||
windows: { enabled: true, combo: 'Ctrl+S' },
|
||||
},
|
||||
toggleQueryResultsPanel: {
|
||||
mac: { enabled: true, combo: 'Meta+Shift+M' },
|
||||
windows: { enabled: true, combo: 'Ctrl+Shift+M' },
|
||||
},
|
||||
},
|
||||
activeTabId: 'tab-1',
|
||||
aiPanelVisible: false,
|
||||
@@ -250,7 +259,7 @@ vi.mock('@monaco-editor/react', () => ({
|
||||
onMount?.(editorState.editor, {
|
||||
editor: { setTheme: vi.fn() },
|
||||
KeyMod: { CtrlCmd: 2048, WinCtrl: 256 },
|
||||
KeyCode: { KeyQ: 81, KeyS: 83 },
|
||||
KeyCode: { KeyM: 77, KeyQ: 81, KeyS: 83 },
|
||||
languages: {
|
||||
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
|
||||
CompletionItemInsertTextRule: { InsertAsSnippet: 1 },
|
||||
@@ -298,7 +307,11 @@ vi.mock('@monaco-editor/react', () => ({
|
||||
vi.mock('./DataGrid', () => ({
|
||||
default: (props: any) => {
|
||||
dataGridState.latestProps = props;
|
||||
return <div data-grid="true" />;
|
||||
return (
|
||||
<div data-grid="true">
|
||||
{props.toolbarExtraActions ?? null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
GONAVI_ROW_KEY: '__gonavi_row_key__',
|
||||
}));
|
||||
@@ -314,6 +327,8 @@ vi.mock('@ant-design/icons', () => {
|
||||
StopOutlined: Icon,
|
||||
RobotOutlined: Icon,
|
||||
DatabaseOutlined: Icon,
|
||||
EyeOutlined: Icon,
|
||||
EyeInvisibleOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -352,24 +367,27 @@ vi.mock('antd', () => {
|
||||
),
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
Select: () => null,
|
||||
Tabs: ({ activeKey, items, onChange }: any) => {
|
||||
Tabs: ({ activeKey, items, onChange, tabBarExtraContent }: any) => {
|
||||
const resolvedActiveKey = tabsState.activeKey ?? activeKey ?? items?.[0]?.key;
|
||||
const activeItem = items?.find((item: any) => item.key === resolvedActiveKey) || items?.[0];
|
||||
return (
|
||||
<div>
|
||||
<div>{items?.map((item: any) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
data-tab-key={item.key}
|
||||
onClick={() => {
|
||||
tabsState.activeKey = item.key;
|
||||
onChange?.(item.key);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}</div>
|
||||
<div>
|
||||
{items?.map((item: any) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
data-tab-key={item.key}
|
||||
onClick={() => {
|
||||
tabsState.activeKey = item.key;
|
||||
onChange?.(item.key);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
{tabBarExtraContent?.right ?? null}
|
||||
</div>
|
||||
<div>{activeItem?.children}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -425,6 +443,34 @@ describe('QueryEditor external SQL save', () => {
|
||||
storeState.saveQuery.mockReset();
|
||||
storeState.savedQueries = [];
|
||||
storeState.activeTabId = 'tab-1';
|
||||
storeState.queryOptions = {
|
||||
maxRows: 5000,
|
||||
showColumnComment: true,
|
||||
showColumnType: true,
|
||||
showQueryResultsPanel: false,
|
||||
};
|
||||
storeState.shortcutOptions = {
|
||||
runQuery: {
|
||||
mac: { enabled: false, combo: '' },
|
||||
windows: { enabled: false, combo: '' },
|
||||
},
|
||||
selectCurrentStatement: {
|
||||
mac: { enabled: false, combo: '' },
|
||||
windows: { enabled: false, combo: '' },
|
||||
},
|
||||
saveQuery: {
|
||||
mac: { enabled: true, combo: 'Meta+S' },
|
||||
windows: { enabled: true, combo: 'Ctrl+S' },
|
||||
},
|
||||
toggleQueryResultsPanel: {
|
||||
mac: { enabled: true, combo: 'Meta+Shift+M' },
|
||||
windows: { enabled: true, combo: 'Ctrl+Shift+M' },
|
||||
},
|
||||
};
|
||||
storeState.setQueryOptions.mockReset();
|
||||
storeState.setQueryOptions.mockImplementation((options: Record<string, unknown>) => {
|
||||
storeState.queryOptions = { ...storeState.queryOptions, ...options };
|
||||
});
|
||||
messageApi.success.mockReset();
|
||||
messageApi.error.mockReset();
|
||||
messageApi.warning.mockReset();
|
||||
@@ -486,6 +532,173 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(editorState.value).toBe('SELECT * FROM ');
|
||||
});
|
||||
|
||||
it('keeps the query results panel hidden by default on first entry', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab()} />);
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL');
|
||||
});
|
||||
|
||||
it('shows the empty query results panel after toggling the results button', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab()} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, '结果').props.onClick();
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).toContain('等待执行 SQL');
|
||||
expect(storeState.setQueryOptions).toHaveBeenCalledWith({ showQueryResultsPanel: true });
|
||||
});
|
||||
|
||||
it('hides the expanded empty query results panel from the inline hide action', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab()} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, '结果').props.onClick();
|
||||
});
|
||||
expect(textContent(renderer.toJSON())).toContain('等待执行 SQL');
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, '隐藏').props.onClick();
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL');
|
||||
expect(storeState.setQueryOptions).toHaveBeenLastCalledWith({ showQueryResultsPanel: false });
|
||||
});
|
||||
|
||||
it('auto expands the query results panel after a successful execution returns rows', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['value'], rows: [{ value: 1 }] }],
|
||||
});
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ query: 'SELECT 1 AS value' })} />);
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).not.toContain('结果 1');
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).toContain('结果 1');
|
||||
expect(storeState.setQueryOptions).toHaveBeenCalledWith({ showQueryResultsPanel: true });
|
||||
});
|
||||
|
||||
it('keeps the inline hide action available after query results render rows', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['value'], rows: [{ value: 1 }] }],
|
||||
});
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ query: 'SELECT 1 AS value' })} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).toContain('结果 1');
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, '隐藏').props.onClick();
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).not.toContain('结果 1');
|
||||
expect(storeState.setQueryOptions).toHaveBeenLastCalledWith({ showQueryResultsPanel: false });
|
||||
});
|
||||
|
||||
it('toggles the query results panel with Ctrl/Cmd+Shift+M', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
|
||||
const windowListeners: Record<string, ((event?: any) => 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(),
|
||||
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}),
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
innerHeight: 900,
|
||||
});
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab()} />);
|
||||
});
|
||||
|
||||
const toggleAction = editorState.editor.addAction.mock.calls
|
||||
.map((call: any[]) => call[0])
|
||||
.find((action: any) => action?.id === 'gonavi.toggleQueryResultsPanel');
|
||||
expect(toggleAction).toMatchObject({
|
||||
label: 'GoNavi: 切换结果区',
|
||||
});
|
||||
expect(toggleAction?.keybindings?.[0]).toBeGreaterThan(0);
|
||||
|
||||
const isMacRuntime = /(Mac|iPhone|iPad|iPod)/i.test(`${navigator.platform || ''} ${navigator.userAgent || ''}`);
|
||||
const createToggleEvent = () => ({
|
||||
ctrlKey: !isMacRuntime,
|
||||
metaKey: isMacRuntime,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
key: 'm',
|
||||
target: null,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
});
|
||||
|
||||
const firstEvent = createToggleEvent();
|
||||
await act(async () => {
|
||||
windowListeners.keydown?.forEach((listener) => listener(firstEvent));
|
||||
});
|
||||
|
||||
expect(firstEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(firstEvent.stopPropagation).toHaveBeenCalled();
|
||||
expect(textContent(renderer.toJSON())).toContain('等待执行 SQL');
|
||||
|
||||
const secondEvent = createToggleEvent();
|
||||
await act(async () => {
|
||||
windowListeners.keydown?.forEach((listener) => listener(secondEvent));
|
||||
});
|
||||
|
||||
expect(secondEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(secondEvent.stopPropagation).toHaveBeenCalled();
|
||||
expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL');
|
||||
});
|
||||
|
||||
it('keeps table name completion available after typing in a fresh query tab', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
autoFetchState.visible = true;
|
||||
@@ -3033,6 +3246,7 @@ describe('QueryEditor external SQL save', () => {
|
||||
const moveListeners: Array<(event: MouseEvent) => void> = [];
|
||||
const upListeners: Array<() => void> = [];
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
storeState.queryOptions.showQueryResultsPanel = true;
|
||||
vi.mocked(document.addEventListener).mockImplementation((type: string, listener: any) => {
|
||||
if (type === 'mousemove') moveListeners.push(listener);
|
||||
if (type === 'mouseup') upListeners.push(listener);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import Editor, { type OnMount } from './MonacoEditor';
|
||||
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
import { format } from 'sql-formatter';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { TabData, ColumnDefinition, IndexDefinition } from '../types';
|
||||
@@ -1977,6 +1977,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const runQueryActionRef = useRef<any>(null);
|
||||
const selectCurrentStatementActionRef = useRef<any>(null);
|
||||
const saveQueryActionRef = useRef<any>(null);
|
||||
const toggleQueryResultsPanelActionRef = useRef<any>(null);
|
||||
const lastExternalQueryRef = useRef<string>(getTabQueryValue(tab));
|
||||
const lastEditorCursorPositionRef = useRef<any>(null);
|
||||
const lastHoverTargetPositionRef = useRef<{ lineNumber: number; column: number } | null>(null);
|
||||
@@ -2021,6 +2022,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions);
|
||||
const queryOptions = useStore(state => state.queryOptions);
|
||||
const setQueryOptions = useStore(state => state.setQueryOptions);
|
||||
const [isResultPanelVisible, setIsResultPanelVisible] = useState(Boolean(queryOptions?.showQueryResultsPanel));
|
||||
const shortcutOptions = useStore(state => state.shortcutOptions);
|
||||
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
|
||||
const runQueryShortcutBinding = useMemo(
|
||||
@@ -2035,10 +2037,28 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
() => resolveShortcutBinding(shortcutOptions, 'saveQuery', activeShortcutPlatform),
|
||||
[activeShortcutPlatform, shortcutOptions],
|
||||
);
|
||||
const toggleQueryResultsPanelShortcutBinding = useMemo(
|
||||
() => resolveShortcutBinding(shortcutOptions, 'toggleQueryResultsPanel', activeShortcutPlatform),
|
||||
[activeShortcutPlatform, shortcutOptions],
|
||||
);
|
||||
const primaryShortcutModifierLabel = useMemo(
|
||||
() => getShortcutPrimaryModifierDisplayLabel(activeShortcutPlatform),
|
||||
[activeShortcutPlatform],
|
||||
);
|
||||
useEffect(() => {
|
||||
setIsResultPanelVisible(Boolean(queryOptions?.showQueryResultsPanel));
|
||||
}, [queryOptions?.showQueryResultsPanel]);
|
||||
const updateResultPanelVisibility = useCallback((visible: boolean) => {
|
||||
setIsResultPanelVisible(visible);
|
||||
setQueryOptions({ showQueryResultsPanel: visible });
|
||||
}, [setQueryOptions]);
|
||||
const toggleResultPanelVisibility = useCallback(() => {
|
||||
setIsResultPanelVisible((previousVisible) => {
|
||||
const nextVisible = !previousVisible;
|
||||
setQueryOptions({ showQueryResultsPanel: nextVisible });
|
||||
return nextVisible;
|
||||
});
|
||||
}, [setQueryOptions]);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
const currentSavedQuery = useMemo(() => {
|
||||
@@ -3116,6 +3136,21 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
}
|
||||
|
||||
const toggleResultsBinding = toggleQueryResultsPanelShortcutBinding;
|
||||
if (toggleResultsBinding?.enabled && toggleResultsBinding.combo) {
|
||||
const keyBinding = comboToMonacoKeyBinding(
|
||||
toggleResultsBinding.combo, monaco.KeyMod, monaco.KeyCode
|
||||
);
|
||||
if (keyBinding) {
|
||||
toggleQueryResultsPanelActionRef.current = editor.addAction({
|
||||
id: 'gonavi.toggleQueryResultsPanel',
|
||||
label: 'GoNavi: 切换结果区',
|
||||
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
|
||||
run: toggleResultPanelVisibility,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// HMR 重载或测试重置时,以全局状态为准,避免本地闭包状态和 provider 列表不同步。
|
||||
sqlCompletionRegistered = Boolean(_g.__gonaviSqlCompletionState.registered);
|
||||
sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables;
|
||||
@@ -4109,6 +4144,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
if (shellConvert.recognized) {
|
||||
if (shellConvert.error) {
|
||||
const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : '';
|
||||
updateResultPanelVisibility(true);
|
||||
setExecutionError(formatSqlExecutionError(shellConvert.error, { prefix }));
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
@@ -4148,6 +4184,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
});
|
||||
if (!res.success) {
|
||||
const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : '';
|
||||
updateResultPanelVisibility(true);
|
||||
setExecutionError(formatSqlExecutionError(res.message, { prefix }));
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
@@ -4214,6 +4251,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nextResultSets.length > 0) {
|
||||
updateResultPanelVisibility(true);
|
||||
}
|
||||
const shouldReplaceAllResults = didExecuteWholeEditor;
|
||||
setResultSets(prev => {
|
||||
const merged = mergeResultSets(prev, nextResultSets, shouldReplaceAllResults);
|
||||
@@ -4313,6 +4353,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return;
|
||||
}
|
||||
|
||||
updateResultPanelVisibility(true);
|
||||
setExecutionError(formatSqlExecutionError(res.message));
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
@@ -4424,6 +4465,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
});
|
||||
}
|
||||
|
||||
if (nextResultSets.length > 0) {
|
||||
updateResultPanelVisibility(true);
|
||||
}
|
||||
const shouldReplaceAllResults = didExecuteWholeEditor;
|
||||
setResultSets(prev => {
|
||||
const merged = mergeResultSets(prev, nextResultSets, shouldReplaceAllResults);
|
||||
@@ -4461,6 +4505,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
message: e.message,
|
||||
dbName: currentDb
|
||||
});
|
||||
updateResultPanelVisibility(true);
|
||||
setExecutionError(formattedError);
|
||||
setResultSets([]);
|
||||
setActiveResultKey('');
|
||||
@@ -4659,6 +4704,37 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
};
|
||||
}, [saveQueryShortcutBinding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toggleQueryResultsPanelActionRef.current) {
|
||||
toggleQueryResultsPanelActionRef.current.dispose();
|
||||
toggleQueryResultsPanelActionRef.current = null;
|
||||
}
|
||||
|
||||
const editor = editorRef.current;
|
||||
const monaco = monacoRef.current;
|
||||
if (!editor || !monaco) return;
|
||||
|
||||
const binding = toggleQueryResultsPanelShortcutBinding;
|
||||
if (!binding?.enabled || !binding.combo) return;
|
||||
|
||||
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
|
||||
if (keyBinding) {
|
||||
toggleQueryResultsPanelActionRef.current = editor.addAction({
|
||||
id: 'gonavi.toggleQueryResultsPanel',
|
||||
label: 'GoNavi: 切换结果区',
|
||||
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
|
||||
run: toggleResultPanelVisibility,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (toggleQueryResultsPanelActionRef.current) {
|
||||
toggleQueryResultsPanelActionRef.current.dispose();
|
||||
toggleQueryResultsPanelActionRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [toggleQueryResultsPanelShortcutBinding, toggleResultPanelVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRunActiveQuery = () => {
|
||||
if (!isActive) {
|
||||
@@ -4907,6 +4983,39 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
};
|
||||
}, [isActive, saveQueryShortcutBinding, handleQuickSave]);
|
||||
|
||||
useEffect(() => {
|
||||
const binding = toggleQueryResultsPanelShortcutBinding;
|
||||
if (!binding?.enabled || !binding.combo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleToggleResultsShortcut = (event: KeyboardEvent) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
if (!isShortcutMatch(event, binding.combo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = editorRef.current;
|
||||
const targetNode = resolveEventTargetNode(event.target);
|
||||
const editorHasFocus = !!editor?.hasTextFocus?.();
|
||||
const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode));
|
||||
if (!editorHasFocus && !inQueryEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleResultPanelVisibility();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleToggleResultsShortcut, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleToggleResultsShortcut, true);
|
||||
};
|
||||
}, [isActive, toggleQueryResultsPanelShortcutBinding, toggleResultPanelVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSaveActiveQuery = () => {
|
||||
if (!isActive) {
|
||||
@@ -5012,6 +5121,65 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
},
|
||||
];
|
||||
|
||||
const resolvedActiveResultKey = activeResultKey || resultSets[0]?.key || '';
|
||||
const activeResultSet = resultSets.find((rs) => rs.key === resolvedActiveResultKey) || null;
|
||||
const activeResultUsesDataGrid = Boolean(
|
||||
activeResultSet &&
|
||||
activeResultSet.resultType !== 'message' &&
|
||||
!(activeResultSet.columns.length === 1 && activeResultSet.columns[0] === 'affectedRows')
|
||||
);
|
||||
const toggleQueryResultsPanelShortcutLabel =
|
||||
toggleQueryResultsPanelShortcutBinding.enabled && toggleQueryResultsPanelShortcutBinding.combo
|
||||
? getShortcutDisplayLabel(toggleQueryResultsPanelShortcutBinding.combo, activeShortcutPlatform)
|
||||
: '';
|
||||
const resultPanelHideTooltipTitle = toggleQueryResultsPanelShortcutLabel
|
||||
? `隐藏结果区(${toggleQueryResultsPanelShortcutLabel})`
|
||||
: '隐藏结果区';
|
||||
|
||||
const resultPanelHideButton = (
|
||||
<Tooltip
|
||||
title={resultPanelHideTooltipTitle}
|
||||
>
|
||||
<Button
|
||||
className="query-result-panel-hide"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeInvisibleOutlined />}
|
||||
onClick={() => updateResultPanelVisibility(false)}
|
||||
>
|
||||
隐藏
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const resultPanelTabsHideButton = (
|
||||
<Tooltip title={resultPanelHideTooltipTitle}>
|
||||
<Button
|
||||
aria-label="隐藏结果区"
|
||||
className="query-result-panel-hide query-result-panel-hide-compact"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeInvisibleOutlined />}
|
||||
onClick={() => updateResultPanelVisibility(false)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const resultPanelToolbarHideButton = (
|
||||
<Tooltip title={resultPanelHideTooltipTitle}>
|
||||
<Button
|
||||
className={isV2Ui ? 'gn-v2-query-result-toolbar-hide' : undefined}
|
||||
icon={<EyeInvisibleOutlined />}
|
||||
onClick={() => updateResultPanelVisibility(false)}
|
||||
>
|
||||
<span>隐藏</span>
|
||||
{isV2Ui && toggleQueryResultsPanelShortcutLabel && (
|
||||
<span className="gn-v2-toolbar-kbd">{toggleQueryResultsPanelShortcutLabel}</span>
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={queryEditorRootRef} className={isV2Ui ? 'gn-v2-query-editor' : undefined} style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<style>{`
|
||||
@@ -5026,11 +5194,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
min-height: 38px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.query-result-tabs .ant-tabs-nav-wrap {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.query-result-tabs .ant-tabs-extra-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.query-result-tabs .ant-tabs-nav-list {
|
||||
align-items: center;
|
||||
width: auto;
|
||||
@@ -5141,8 +5315,38 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #666;
|
||||
}
|
||||
.query-result-panel-header {
|
||||
flex: 0 0 auto;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.query-result-panel-header-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
.query-result-panel-hide {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.query-result-panel-hide-compact {
|
||||
min-width: 28px;
|
||||
padding: 0 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
`}</style>
|
||||
<div ref={editorPaneRef} className={isV2Ui ? 'gn-v2-query-editor-pane' : undefined}>
|
||||
<div
|
||||
ref={editorPaneRef}
|
||||
className={isV2Ui ? 'gn-v2-query-editor-pane' : undefined}
|
||||
style={{ display: 'flex', flexDirection: 'column', minHeight: 0, flex: isResultPanelVisible ? '0 0 auto' : '1 1 auto' }}
|
||||
>
|
||||
<div className={isV2Ui ? 'gn-v2-query-toolbar' : undefined} style={{ padding: '4px 8px 8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-query-toolbar-selects' : undefined}
|
||||
@@ -5233,6 +5437,21 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
</Dropdown>
|
||||
</Button.Group>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
toggleQueryResultsPanelShortcutBinding.enabled && toggleQueryResultsPanelShortcutBinding.combo
|
||||
? `${isResultPanelVisible ? '隐藏结果区' : '显示结果区'}(${getShortcutDisplayLabel(toggleQueryResultsPanelShortcutBinding.combo, activeShortcutPlatform)})`
|
||||
: (isResultPanelVisible ? '隐藏结果区' : '显示结果区')
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={isResultPanelVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={toggleResultPanelVisibility}
|
||||
>
|
||||
结果
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Dropdown menu={{ items: [
|
||||
{ key: 'ai-generate', label: '生成 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('generate') },
|
||||
{ key: 'ai-explain', label: '解释 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('explain') },
|
||||
@@ -5245,7 +5464,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={editorShellRef} className={isV2Ui ? 'gn-v2-query-monaco-shell' : undefined} style={{ height: editorHeight, minHeight: '100px' }}>
|
||||
<div
|
||||
ref={editorShellRef}
|
||||
className={isV2Ui ? 'gn-v2-query-monaco-shell' : undefined}
|
||||
style={isResultPanelVisible ? { height: editorHeight, minHeight: '100px' } : { flex: '1 1 auto', minHeight: 0 }}
|
||||
>
|
||||
<Editor
|
||||
height="100%"
|
||||
gonaviTypography="code"
|
||||
@@ -5273,28 +5496,32 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-query-resizer' : undefined}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
height: '5px',
|
||||
cursor: 'row-resize',
|
||||
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
|
||||
flexShrink: 0,
|
||||
zIndex: 10
|
||||
}}
|
||||
title="拖动调整高度"
|
||||
/>
|
||||
{isResultPanelVisible && (
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-query-resizer' : undefined}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
height: '5px',
|
||||
cursor: 'row-resize',
|
||||
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
|
||||
flexShrink: 0,
|
||||
zIndex: 10
|
||||
}}
|
||||
title="拖动调整高度"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={isV2Ui ? 'gn-v2-query-results' : undefined} style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{isResultPanelVisible && (
|
||||
<div className={isV2Ui ? 'gn-v2-query-results' : undefined} style={{ position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{resultSets.length > 0 ? (
|
||||
<Tabs
|
||||
className="query-result-tabs"
|
||||
activeKey={activeResultKey || resultSets[0]?.key}
|
||||
activeKey={resolvedActiveResultKey}
|
||||
onChange={setActiveResultKey}
|
||||
animated={false}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
tabBarExtraContent={!activeResultUsesDataGrid ? { right: resultPanelTabsHideButton } : undefined}
|
||||
items={resultSets.map((rs, idx) => ({
|
||||
key: rs.key,
|
||||
label: (
|
||||
@@ -5427,6 +5654,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
editLocator={rs.editLocator}
|
||||
onReload={() => handleReloadResult(rs.key, rs.sql)}
|
||||
readOnly={rs.readOnly}
|
||||
toolbarExtraActions={resolvedActiveResultKey === rs.key ? resultPanelToolbarHideButton : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -5434,6 +5662,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}))}
|
||||
/>
|
||||
) : executionError ? (
|
||||
<>
|
||||
<div className={isV2Ui ? 'query-result-panel-header gn-v2-query-result-panel-header' : 'query-result-panel-header'}>
|
||||
<span className="query-result-panel-header-title">结果区</span>
|
||||
{resultPanelHideButton}
|
||||
</div>
|
||||
<div className={isV2Ui ? 'gn-v2-query-error' : undefined} style={{ flex: 1, minHeight: 0, padding: 24, display: 'flex', flexDirection: 'column', gap: 16, background: darkMode ? '#1e1e1e' : '#fafafa', overflow: 'auto' }}>
|
||||
<div style={{ color: '#ff4d4f', fontWeight: 'bold', fontSize: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CloseOutlined />
|
||||
@@ -5462,7 +5695,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={isV2Ui ? 'query-result-panel-header gn-v2-query-result-panel-header' : 'query-result-panel-header'}>
|
||||
<span className="query-result-panel-header-title">结果区</span>
|
||||
{resultPanelHideButton}
|
||||
</div>
|
||||
<div className={isV2Ui ? 'gn-v2-query-empty' : undefined} style={{ flex: 1, minHeight: 0 }}>
|
||||
{isV2Ui && (
|
||||
<div>
|
||||
@@ -5471,8 +5710,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={saveModalMode === 'rename' ? '重命名查询' : '保存查询'}
|
||||
|
||||
@@ -1111,6 +1111,7 @@ export interface QueryOptions {
|
||||
maxRows: number;
|
||||
showColumnComment: boolean;
|
||||
showColumnType: boolean;
|
||||
showQueryResultsPanel: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
@@ -1581,13 +1582,16 @@ const sanitizeQueryOptions = (value: unknown): QueryOptions => {
|
||||
typeof raw.showColumnComment === "boolean" ? raw.showColumnComment : true;
|
||||
const showColumnType =
|
||||
typeof raw.showColumnType === "boolean" ? raw.showColumnType : true;
|
||||
const showQueryResultsPanel =
|
||||
typeof raw.showQueryResultsPanel === "boolean" ? raw.showQueryResultsPanel : false;
|
||||
if (!Number.isFinite(maxRows) || maxRows <= 0) {
|
||||
return { maxRows: 5000, showColumnComment, showColumnType };
|
||||
return { maxRows: 5000, showColumnComment, showColumnType, showQueryResultsPanel };
|
||||
}
|
||||
return {
|
||||
maxRows: Math.min(50000, Math.trunc(maxRows)),
|
||||
showColumnComment,
|
||||
showColumnType,
|
||||
showQueryResultsPanel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1975,6 +1979,7 @@ export const useStore = create<AppState>()(
|
||||
maxRows: 5000,
|
||||
showColumnComment: true,
|
||||
showColumnType: true,
|
||||
showQueryResultsPanel: false,
|
||||
},
|
||||
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
|
||||
sqlSnippets: DEFAULT_SQL_SNIPPETS,
|
||||
|
||||
@@ -3277,6 +3277,18 @@ body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-ai-insight-button .gn-v2-tool
|
||||
color: var(--gn-info);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-query-result-toolbar-hide {
|
||||
border-color: var(--gn-br-1) !important;
|
||||
background: var(--gn-bg-panel) !important;
|
||||
color: var(--gn-fg-2) !important;
|
||||
font-weight: 650 !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .gn-v2-query-result-toolbar-hide:hover {
|
||||
background: var(--gn-bg-hover) !important;
|
||||
color: var(--gn-fg-1) !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-smart-filter-panel {
|
||||
background: var(--gn-bg-panel-2) !important;
|
||||
border-top: 0.5px solid var(--gn-br-1);
|
||||
@@ -4920,6 +4932,12 @@ body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-n
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-extra-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab {
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
@@ -4984,6 +5002,15 @@ body[data-ui-version="v2"] .gn-v2-query-results .query-result-tab-count {
|
||||
color: var(--gn-fg-2);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-query-result-panel-header {
|
||||
background: var(--gn-bg-panel-2);
|
||||
border-bottom-color: var(--gn-br-1);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-query-result-panel-header .query-result-panel-header-title {
|
||||
color: var(--gn-fg-3);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-query-empty,
|
||||
body[data-ui-version="v2"] .gn-v2-query-success {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user