diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
index d568a3dd..ce220c94 100644
--- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx
+++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
@@ -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();
});
+ 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(
+ ,
+ { 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(
+ ,
+ { 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 void)[]> = {};
editorState.domNode = {
diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx
index 05535212..d3bebf66 100644
--- a/frontend/src/components/QueryEditor.tsx
+++ b/frontend/src/components/QueryEditor.tsx
@@ -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]);
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index dcd60f38..60be8f11 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -78,6 +78,10 @@ import {
normalizeConnectionProtectionConfig,
resolveConnectionProtectionConfig,
} from "./utils/connectionReadOnly";
+import {
+ DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO,
+ sanitizeQueryEditorEditorHeightRatio,
+} from "./utils/queryEditorSplitLayout";
export type TableDoubleClickAction = "open-data" | "open-design";
@@ -1234,6 +1238,7 @@ export interface QueryOptions {
showSidebarTableComment?: boolean;
showColumnType: boolean;
showQueryResultsPanel: boolean;
+ queryEditorEditorHeightRatio: number;
}
export interface DataEditTransactionOptions {
@@ -1931,8 +1936,18 @@ const sanitizeQueryOptions = (value: unknown): QueryOptions => {
typeof raw.showColumnType === "boolean" ? raw.showColumnType : true;
const showQueryResultsPanel =
typeof raw.showQueryResultsPanel === "boolean" ? raw.showQueryResultsPanel : false;
+ const queryEditorEditorHeightRatio = sanitizeQueryEditorEditorHeightRatio(
+ raw.queryEditorEditorHeightRatio,
+ );
if (!Number.isFinite(maxRows) || maxRows <= 0) {
- return { maxRows: 5000, showColumnComment, showSidebarTableComment, showColumnType, showQueryResultsPanel };
+ return {
+ maxRows: 5000,
+ showColumnComment,
+ showSidebarTableComment,
+ showColumnType,
+ showQueryResultsPanel,
+ queryEditorEditorHeightRatio,
+ };
}
return {
maxRows: Math.min(50000, Math.trunc(maxRows)),
@@ -1940,6 +1955,7 @@ const sanitizeQueryOptions = (value: unknown): QueryOptions => {
showSidebarTableComment,
showColumnType,
showQueryResultsPanel,
+ queryEditorEditorHeightRatio,
};
};
@@ -2370,6 +2386,7 @@ export const useStore = create()(
showSidebarTableComment: false,
showColumnType: true,
showQueryResultsPanel: false,
+ queryEditorEditorHeightRatio: DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO,
},
dataEditTransactionOptions: {
commitMode: "manual",
diff --git a/frontend/src/utils/queryEditorSplitLayout.test.ts b/frontend/src/utils/queryEditorSplitLayout.test.ts
new file mode 100644
index 00000000..b5f3a4c9
--- /dev/null
+++ b/frontend/src/utils/queryEditorSplitLayout.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO,
+ MAX_QUERY_EDITOR_EDITOR_HEIGHT_RATIO,
+ MIN_QUERY_EDITOR_EDITOR_HEIGHT,
+ MIN_QUERY_EDITOR_EDITOR_HEIGHT_RATIO,
+ MIN_QUERY_EDITOR_RESULT_HEIGHT,
+ clampQueryEditorEditorHeight,
+ resolveQueryEditorEditorHeightFromRatio,
+ resolveQueryEditorEditorHeightRatio,
+ sanitizeQueryEditorEditorHeightRatio,
+} from './queryEditorSplitLayout';
+
+describe('query editor split layout', () => {
+ it('sanitizes persisted editor height ratios', () => {
+ expect(sanitizeQueryEditorEditorHeightRatio(undefined)).toBe(DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO);
+ expect(sanitizeQueryEditorEditorHeightRatio(Number.NaN)).toBe(DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO);
+ expect(sanitizeQueryEditorEditorHeightRatio(0.01)).toBe(MIN_QUERY_EDITOR_EDITOR_HEIGHT_RATIO);
+ expect(sanitizeQueryEditorEditorHeightRatio(0.99)).toBe(MAX_QUERY_EDITOR_EDITOR_HEIGHT_RATIO);
+ expect(sanitizeQueryEditorEditorHeightRatio(0.62)).toBe(0.62);
+ });
+
+ it('resolves editor height from a persisted ratio while leaving room for results', () => {
+ expect(resolveQueryEditorEditorHeightFromRatio(0.5, 800)).toBe(400);
+ expect(resolveQueryEditorEditorHeightFromRatio(0.9, 800)).toBe(680);
+ expect(resolveQueryEditorEditorHeightFromRatio(0.01, 800)).toBe(144);
+ expect(resolveQueryEditorEditorHeightFromRatio(0.5, 0)).toBe(MIN_QUERY_EDITOR_EDITOR_HEIGHT);
+ });
+
+ it('clamps editor height to valid split bounds', () => {
+ expect(clampQueryEditorEditorHeight(20, 800)).toBe(MIN_QUERY_EDITOR_EDITOR_HEIGHT);
+ expect(clampQueryEditorEditorHeight(760, 800)).toBe(800 - MIN_QUERY_EDITOR_RESULT_HEIGHT);
+ expect(clampQueryEditorEditorHeight(360, 800)).toBe(360);
+ });
+
+ it('resolves persisted ratio from the final editor height', () => {
+ expect(resolveQueryEditorEditorHeightRatio(480, 800)).toBe(0.6);
+ expect(resolveQueryEditorEditorHeightRatio(20, 800)).toBe(MIN_QUERY_EDITOR_EDITOR_HEIGHT_RATIO);
+ expect(resolveQueryEditorEditorHeightRatio(760, 800)).toBe(MAX_QUERY_EDITOR_EDITOR_HEIGHT_RATIO);
+ expect(resolveQueryEditorEditorHeightRatio(480, 0)).toBe(DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO);
+ });
+});
diff --git a/frontend/src/utils/queryEditorSplitLayout.ts b/frontend/src/utils/queryEditorSplitLayout.ts
new file mode 100644
index 00000000..625122fd
--- /dev/null
+++ b/frontend/src/utils/queryEditorSplitLayout.ts
@@ -0,0 +1,66 @@
+export const DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO = 0.5;
+export const MIN_QUERY_EDITOR_EDITOR_HEIGHT_RATIO = 0.18;
+export const MAX_QUERY_EDITOR_EDITOR_HEIGHT_RATIO = 0.85;
+export const MIN_QUERY_EDITOR_EDITOR_HEIGHT = 100;
+export const MIN_QUERY_EDITOR_RESULT_HEIGHT = 120;
+
+export const sanitizeQueryEditorEditorHeightRatio = (value: unknown): number => {
+ const ratio = Number(value);
+ if (!Number.isFinite(ratio)) {
+ return DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO;
+ }
+ return Math.min(
+ MAX_QUERY_EDITOR_EDITOR_HEIGHT_RATIO,
+ Math.max(MIN_QUERY_EDITOR_EDITOR_HEIGHT_RATIO, ratio),
+ );
+};
+
+export const clampQueryEditorEditorHeight = (
+ height: unknown,
+ availableHeight: unknown,
+): number => {
+ const rawHeight = Number(height);
+ const rawAvailableHeight = Number(availableHeight);
+ const normalizedHeight = Number.isFinite(rawHeight) ? rawHeight : MIN_QUERY_EDITOR_EDITOR_HEIGHT;
+ if (!Number.isFinite(rawAvailableHeight) || rawAvailableHeight <= 0) {
+ return Math.max(MIN_QUERY_EDITOR_EDITOR_HEIGHT, Math.round(normalizedHeight));
+ }
+ const maxEditorHeight = Math.max(
+ MIN_QUERY_EDITOR_EDITOR_HEIGHT,
+ rawAvailableHeight - MIN_QUERY_EDITOR_RESULT_HEIGHT,
+ );
+ return Math.round(Math.max(
+ MIN_QUERY_EDITOR_EDITOR_HEIGHT,
+ Math.min(maxEditorHeight, normalizedHeight),
+ ));
+};
+
+export const resolveQueryEditorEditorHeightFromRatio = (
+ ratio: unknown,
+ availableHeight: unknown,
+): number => {
+ const rawAvailableHeight = Number(availableHeight);
+ if (!Number.isFinite(rawAvailableHeight) || rawAvailableHeight <= 0) {
+ return MIN_QUERY_EDITOR_EDITOR_HEIGHT;
+ }
+ return clampQueryEditorEditorHeight(
+ rawAvailableHeight * sanitizeQueryEditorEditorHeightRatio(ratio),
+ rawAvailableHeight,
+ );
+};
+
+export const resolveQueryEditorEditorHeightRatio = (
+ editorHeight: unknown,
+ availableHeight: unknown,
+): number => {
+ const rawEditorHeight = Number(editorHeight);
+ const rawAvailableHeight = Number(availableHeight);
+ if (
+ !Number.isFinite(rawEditorHeight)
+ || !Number.isFinite(rawAvailableHeight)
+ || rawAvailableHeight <= 0
+ ) {
+ return DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO;
+ }
+ return sanitizeQueryEditorEditorHeightRatio(rawEditorHeight / rawAvailableHeight);
+};