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); +};