feat(query-editor): 记住查询编辑区结果区比例

- 持久化查询编辑器与结果面板的分割比例
- 新开查询 Tab 时按已保存比例恢复编辑区高度
- 补充分割比例计算与 QueryEditor 回归测试

Fixes #538
This commit is contained in:
Syngnat
2026-06-27 18:21:24 +08:00
parent 60eb696859
commit 6c53ef4eff
5 changed files with 303 additions and 5 deletions

View File

@@ -57,6 +57,7 @@ const storeState = vi.hoisted(() => ({
showColumnComment: true,
showColumnType: true,
showQueryResultsPanel: false,
queryEditorEditorHeightRatio: 0.5,
},
setQueryOptions: vi.fn(),
sqlEditorTransactionOptions: {
@@ -581,6 +582,29 @@ const createDefaultConnections = () => ([
},
]);
const createQueryEditorSplitNodeMock = (element: any) => {
const className = String(element?.props?.className || '');
if (className.includes('gn-v2-query-monaco-shell')) {
return {
style: {},
getBoundingClientRect: () => ({ height: 300 }),
};
}
if (className.includes('gn-v2-query-editor-pane')) {
return {
style: {},
getBoundingClientRect: () => ({ height: 405 }),
};
}
if (className.includes('gn-v2-query-editor')) {
return {
style: {},
getBoundingClientRect: () => ({ height: 805 }),
};
}
return null;
};
describe('QueryEditor external SQL save', () => {
beforeEach(() => {
const completionState = (globalThis as any).__gonaviSqlCompletionState;
@@ -619,11 +643,13 @@ describe('QueryEditor external SQL save', () => {
storeState.activeTabId = 'tab-1';
storeState.aiPanelVisible = false;
storeState.setAIPanelVisible.mockReset();
storeState.appearance.uiVersion = 'legacy';
storeState.queryOptions = {
maxRows: 5000,
showColumnComment: true,
showColumnType: true,
showQueryResultsPanel: false,
queryEditorEditorHeightRatio: 0.5,
};
storeState.sqlEditorTransactionOptions = {
commitMode: 'manual',
@@ -8187,6 +8213,11 @@ describe('QueryEditor external SQL save', () => {
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ resultPanelVisible: true })} />);
});
await act(async () => {
frameCallbacks.splice(0).forEach((callback) => callback(0));
});
vi.mocked(window.requestAnimationFrame).mockClear();
editorState.editor.layout.mockClear();
const resizer = renderer.root.find((node) => node.props?.title === '拖动调整高度');
await act(async () => {
@@ -8211,6 +8242,60 @@ describe('QueryEditor external SQL save', () => {
expect(document.removeEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function));
});
it('persists the editor and result panel split ratio after dragging the splitter', async () => {
storeState.appearance.uiVersion = 'v2';
const moveListeners: Array<(event: MouseEvent) => void> = [];
const upListeners: Array<() => void> = [];
vi.mocked(document.addEventListener).mockImplementation((type: string, listener: any) => {
if (type === 'mousemove') moveListeners.push(listener);
if (type === 'mouseup') upListeners.push(listener);
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<QueryEditor tab={createTab({ resultPanelVisible: true })} />,
{ createNodeMock: createQueryEditorSplitNodeMock },
);
});
const resizer = renderer.root.find((node) => node.props?.title === '拖动调整高度');
await act(async () => {
resizer.props.onMouseDown({ clientY: 300, preventDefault: vi.fn() });
moveListeners.forEach((listener) => listener({ clientY: 420 } as MouseEvent));
});
await act(async () => {
upListeners.forEach((listener) => listener());
});
expect(storeState.setQueryOptions).toHaveBeenCalledWith({
queryEditorEditorHeightRatio: 0.6,
});
});
it('applies the persisted editor and result split ratio when opening another query tab', async () => {
storeState.appearance.uiVersion = 'v2';
storeState.activeTabId = 'tab-2';
storeState.queryOptions = {
...storeState.queryOptions,
queryEditorEditorHeightRatio: 0.75,
};
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<QueryEditor tab={createTab({ id: 'tab-2', resultPanelVisible: true })} />,
{ createNodeMock: createQueryEditorSplitNodeMock },
);
});
const editorShell = renderer.root.find((node) => {
const className = String(node.props?.className || '');
return className.includes('gn-v2-query-monaco-shell');
});
expect(editorShell.props.style.height).toBe(525);
});
it('inserts sidebar object text when dropped into the SQL editor', async () => {
const domListeners: Record<string, ((event?: any) => void)[]> = {};
editorState.domNode = {

View File

@@ -36,6 +36,12 @@ import { t as translate } from '../i18n';
import { buildSqlAnalysisWorkbenchTab } from '../utils/sqlAnalysisTab';
import { isLocalizedUntitledQueryTitle } from '../utils/queryTabTitle';
import { buildSqlServerObjectDefinitionQueries } from '../utils/sqlServerObjectDefinition';
import {
clampQueryEditorEditorHeight,
resolveQueryEditorEditorHeightFromRatio,
resolveQueryEditorEditorHeightRatio,
sanitizeQueryEditorEditorHeightRatio,
} from '../utils/queryEditorSplitLayout';
import {
DUCKDB_ROWID_LOCATOR_COLUMN,
ORACLE_ROWID_LOCATOR_COLUMN,
@@ -783,6 +789,9 @@ 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 queryEditorEditorHeightRatio = sanitizeQueryEditorEditorHeightRatio(
queryOptions?.queryEditorEditorHeightRatio,
);
const sqlEditorTransactionOptions = useStore(state => state.sqlEditorTransactionOptions);
const setSqlEditorTransactionOptions = useStore(state => state.setSqlEditorTransactionOptions);
const [isResultPanelVisible, setIsResultPanelVisible] = useState(
@@ -1801,11 +1810,78 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
setCurrentQueryId('');
};
const resolveEditorSplitAvailableHeight = useCallback(() => {
const rootRect = queryEditorRootRef.current?.getBoundingClientRect?.();
const paneRect = editorPaneRef.current?.getBoundingClientRect?.();
const shellRect = editorShellRef.current?.getBoundingClientRect?.();
const rootHeight = Number(rootRect?.height || 0);
const paneHeight = Number(paneRect?.height || 0);
const shellHeight = Number(shellRect?.height || 0);
if (!Number.isFinite(rootHeight) || rootHeight <= 0) {
return 0;
}
const nonEditorPaneHeight = paneHeight > 0 && shellHeight > 0
? Math.max(0, paneHeight - shellHeight)
: 0;
const availableHeight = rootHeight - nonEditorPaneHeight;
return Number.isFinite(availableHeight) && availableHeight > 0 ? availableHeight : 0;
}, []);
const clampEditorHeight = useCallback((height: number) => {
const availableHeight = resolveEditorSplitAvailableHeight();
if (availableHeight > 0) {
return clampQueryEditorEditorHeight(height, availableHeight);
}
const viewportHeight = Number.isFinite(window.innerHeight) ? window.innerHeight : 800;
const maxHeight = Math.max(100, viewportHeight - 200);
return Math.max(100, Math.min(maxHeight, height));
}, []);
}, [resolveEditorSplitAvailableHeight]);
const applyEditorHeightRatio = useCallback(() => {
const availableHeight = resolveEditorSplitAvailableHeight();
if (availableHeight <= 0 || dragRef.current) return;
const nextHeight = resolveQueryEditorEditorHeightFromRatio(
queryEditorEditorHeightRatio,
availableHeight,
);
pendingEditorHeightRef.current = nextHeight;
setEditorHeight(previousHeight => previousHeight === nextHeight ? previousHeight : nextHeight);
}, [queryEditorEditorHeightRatio, resolveEditorSplitAvailableHeight]);
useEffect(() => {
if (!isResultPanelVisible || !isActive) return;
let frame: number | null = null;
const requestFrame = typeof window.requestAnimationFrame === 'function'
? window.requestAnimationFrame.bind(window)
: (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 16);
const cancelFrame = typeof window.cancelAnimationFrame === 'function'
? window.cancelAnimationFrame.bind(window)
: window.clearTimeout.bind(window);
const scheduleApply = () => {
if (frame !== null) return;
frame = requestFrame(() => {
frame = null;
applyEditorHeightRatio();
});
};
scheduleApply();
const ResizeObserverCtor = typeof ResizeObserver === 'function' ? ResizeObserver : null;
const resizeObserver = ResizeObserverCtor ? new ResizeObserverCtor(scheduleApply) : null;
if (resizeObserver) {
if (queryEditorRootRef.current) resizeObserver.observe(queryEditorRootRef.current);
if (editorPaneRef.current) resizeObserver.observe(editorPaneRef.current);
}
window.addEventListener('resize', scheduleApply);
return () => {
if (frame !== null) {
cancelFrame(frame);
frame = null;
}
resizeObserver?.disconnect();
window.removeEventListener('resize', scheduleApply);
};
}, [applyEditorHeightRatio, isActive, isResultPanelVisible, tab.id]);
const applyEditorHeightToDom = useCallback(() => {
const nextHeight = pendingEditorHeightRef.current;
@@ -1856,15 +1932,26 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
pendingEditorHeightRef.current = finalHeight;
applyEditorHeightToDom();
setEditorHeight(finalHeight);
const availableHeight = resolveEditorSplitAvailableHeight();
if (availableHeight > 0) {
setQueryOptions({
queryEditorEditorHeightRatio: resolveQueryEditorEditorHeightRatio(
finalHeight,
availableHeight,
),
});
}
}
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}, [applyEditorHeightToDom, cancelEditorResizeFrame, handleMouseMove]);
}, [applyEditorHeightToDom, cancelEditorResizeFrame, handleMouseMove, resolveEditorSplitAvailableHeight, setQueryOptions]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startY: e.clientY, startHeight: editorHeight, currentHeight: editorHeight };
pendingEditorHeightRef.current = editorHeight;
const currentEditorHeight = Number(editorShellRef.current?.getBoundingClientRect?.().height || editorHeight);
const startHeight = Number.isFinite(currentEditorHeight) && currentEditorHeight > 0 ? currentEditorHeight : editorHeight;
dragRef.current = { startY: e.clientY, startHeight, currentHeight: startHeight };
pendingEditorHeightRef.current = startHeight;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [editorHeight, handleMouseMove, handleMouseUp]);