diff --git a/frontend/src/App.css b/frontend/src/App.css index 74ad92ec..a1773110 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -697,5 +697,5 @@ body[data-theme='dark'] .gonavi-query-editor-db-token { /* Legacy sidebar resize bounds — mirror v2 .gn-v2-app-sider so Ant Design inline width locks do not collapse drag range. */ body[data-ui-version="legacy"] .ant-layout-sider { min-width: 232px !important; - max-width: 420px !important; + max-width: min(960px, calc(100vw - 360px)) !important; } diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 32da0f27..6ce7f4ad 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -26,6 +26,10 @@ const appSidebarResizeSource = readFileSync( fileURLToPath(new globalThis.URL('./hooks/useAppSidebarResize.ts', import.meta.url)), 'utf8', ); +const sidebarLayoutSource = readFileSync( + fileURLToPath(new globalThis.URL('./utils/sidebarLayout.ts', import.meta.url)), + 'utf8', +); const getGlobalShortcutCaseBlock = (action: string) => { const caseToken = `case '${action}':`; @@ -285,8 +289,12 @@ describe('tool center menu entries', () => { expect(appSidebarResizeSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;'); }); - it('keeps legacy sidebar resize bounds aligned with the v2 sider CSS limits', () => { - expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*420px\s*!important;/s); + it('keeps sidebar resize bounds aligned across drag logic and sider CSS limits', () => { + expect(sidebarLayoutSource).toContain('export const SIDEBAR_RESIZE_MAX_WIDTH = 960;'); + expect(sidebarLayoutSource).toContain('export const SIDEBAR_MIN_WORKBENCH_WIDTH = 360;'); + expect(appSidebarResizeSource).toContain('resolveSidebarResizeMaxWidth(window.innerWidth, minWidth)'); + expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*min\(960px,\s*calc\(100vw - 360px\)\)\s*!important;/s); + expect(v2ThemeCss).toMatch(/body\[data-ui-version="v2"\]\s+\.gn-v2-app-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*min\(960px,\s*calc\(100vw - 360px\)\)\s*!important;/s); }); it('keeps connection modal warm-mounted while leaving the other heavyweight modals conditional', () => { diff --git a/frontend/src/hooks/useAppSidebarResize.ts b/frontend/src/hooks/useAppSidebarResize.ts index fc657dd8..b86f412b 100644 --- a/frontend/src/hooks/useAppSidebarResize.ts +++ b/frontend/src/hooks/useAppSidebarResize.ts @@ -1,7 +1,9 @@ import React, { useRef } from 'react'; - -const SIDEBAR_RESIZE_MIN_WIDTH = 200; -const SIDEBAR_RESIZE_MAX_WIDTH = 600; +import { + SIDEBAR_RESIZE_MAX_WIDTH, + SIDEBAR_RESIZE_MIN_WIDTH, + resolveSidebarResizeMaxWidth, +} from '../utils/sidebarLayout'; type SidebarResizeBounds = { minWidth: number; maxWidth: number }; type SidebarResizeDragState = SidebarResizeBounds & { @@ -23,7 +25,8 @@ const resolveSidebarResizeBounds = (siderElement: Element | null): SidebarResize const cssMinWidth = parseCssPixelValue(computed.minWidth); const cssMaxWidth = parseCssPixelValue(computed.maxWidth); const minWidth = Math.max(SIDEBAR_RESIZE_MIN_WIDTH, cssMinWidth && cssMinWidth > 0 ? cssMinWidth : SIDEBAR_RESIZE_MIN_WIDTH); - const maxWidth = Math.max(minWidth, Math.min(SIDEBAR_RESIZE_MAX_WIDTH, cssMaxWidth && cssMaxWidth > 0 ? cssMaxWidth : SIDEBAR_RESIZE_MAX_WIDTH)); + const viewportMaxWidth = resolveSidebarResizeMaxWidth(window.innerWidth, minWidth); + const maxWidth = Math.max(minWidth, Math.min(viewportMaxWidth, cssMaxWidth && cssMaxWidth > 0 ? cssMaxWidth : viewportMaxWidth)); return { minWidth, maxWidth }; }; diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index 0648731e..2fd7b942 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SIDEBAR_RESIZE_MAX_WIDTH } from './utils/sidebarLayout'; class MemoryStorage implements Storage { private data = new Map(); @@ -360,6 +361,30 @@ describe('store appearance persistence', () => { expect(appearance.v2SidebarPersistedFilter).toHaveLength(120); }); + it('persists wider sidebar widths and clamps oversized restored values', async () => { + const { useStore } = await importStore(); + + useStore.getState().setSidebarWidth(880); + expect(useStore.getState().sidebarWidth).toBe(880); + + let persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}'); + expect(persisted.state.sidebarWidth).toBe(880); + + useStore.getState().setSidebarWidth(1200); + expect(useStore.getState().sidebarWidth).toBe(SIDEBAR_RESIZE_MAX_WIDTH); + + storage.setItem('lite-db-storage', JSON.stringify({ + state: { + sidebarWidth: 1200, + }, + version: 13, + })); + + vi.resetModules(); + const reloaded = await importStore(); + expect(reloaded.useStore.getState().sidebarWidth).toBe(SIDEBAR_RESIZE_MAX_WIDTH); + }); + it('persists tab display appearance settings and sanitizes invalid elements', async () => { const { useStore } = await importStore(); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index dfcb0b3f..fb7275f8 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -87,6 +87,7 @@ import { DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO, sanitizeQueryEditorEditorHeightRatio, } from "./utils/queryEditorSplitLayout"; +import { sanitizeSidebarWidth } from "./utils/sidebarLayout"; import { DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS, applySidebarTableMetadataFieldOrder, @@ -2330,12 +2331,6 @@ const sanitizeWindowState = ( return "normal"; }; -const sanitizeSidebarWidth = (value: unknown): number => { - const parsed = Number(value); - if (!Number.isFinite(parsed)) return 330; - return Math.max(200, Math.min(600, Math.trunc(parsed))); -}; - const sanitizeWindowBounds = ( value: unknown, ): { width: number; height: number; x: number; y: number } | null => { @@ -3601,7 +3596,7 @@ export const useStore = create()( setWindowState: (state) => set({ windowState: state }), setSidebarWidth: (width) => - set({ sidebarWidth: Math.max(200, Math.min(600, Math.trunc(width))) }), + set({ sidebarWidth: sanitizeSidebarWidth(width) }), // AI actions toggleAIPanel: () => diff --git a/frontend/src/utils/sidebarLayout.test.ts b/frontend/src/utils/sidebarLayout.test.ts new file mode 100644 index 00000000..dae8c911 --- /dev/null +++ b/frontend/src/utils/sidebarLayout.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SIDEBAR_WIDTH, + SIDEBAR_RESIZE_MAX_WIDTH, + SIDEBAR_RESIZE_MIN_WIDTH, + resolveSidebarResizeMaxWidth, + sanitizeSidebarWidth, +} from './sidebarLayout'; + +describe('sidebar layout bounds', () => { + it('allows wider persisted sidebar widths while keeping invalid values safe', () => { + expect(sanitizeSidebarWidth(880)).toBe(880); + expect(sanitizeSidebarWidth(1200)).toBe(SIDEBAR_RESIZE_MAX_WIDTH); + expect(sanitizeSidebarWidth(120)).toBe(SIDEBAR_RESIZE_MIN_WIDTH); + expect(sanitizeSidebarWidth('bad')).toBe(DEFAULT_SIDEBAR_WIDTH); + }); + + it('keeps enough workbench space when resolving drag width on smaller windows', () => { + expect(resolveSidebarResizeMaxWidth(1600)).toBe(SIDEBAR_RESIZE_MAX_WIDTH); + expect(resolveSidebarResizeMaxWidth(1180)).toBe(820); + expect(resolveSidebarResizeMaxWidth(480)).toBe(SIDEBAR_RESIZE_MIN_WIDTH); + }); +}); diff --git a/frontend/src/utils/sidebarLayout.ts b/frontend/src/utils/sidebarLayout.ts new file mode 100644 index 00000000..a7092e0b --- /dev/null +++ b/frontend/src/utils/sidebarLayout.ts @@ -0,0 +1,27 @@ +export const DEFAULT_SIDEBAR_WIDTH = 330; +export const SIDEBAR_RESIZE_MIN_WIDTH = 200; +export const SIDEBAR_SIDER_MIN_WIDTH = 232; +export const SIDEBAR_RESIZE_MAX_WIDTH = 960; +export const SIDEBAR_MIN_WORKBENCH_WIDTH = 360; + +export const resolveSidebarResizeMaxWidth = ( + viewportWidth: unknown, + minWidth = SIDEBAR_RESIZE_MIN_WIDTH, +): number => { + const parsedViewportWidth = Number(viewportWidth); + if (!Number.isFinite(parsedViewportWidth) || parsedViewportWidth <= 0) { + return SIDEBAR_RESIZE_MAX_WIDTH; + } + + const viewportLimitedWidth = Math.trunc(parsedViewportWidth) - SIDEBAR_MIN_WORKBENCH_WIDTH; + return Math.max(minWidth, Math.min(SIDEBAR_RESIZE_MAX_WIDTH, viewportLimitedWidth)); +}; + +export const sanitizeSidebarWidth = (value: unknown): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return DEFAULT_SIDEBAR_WIDTH; + return Math.max( + SIDEBAR_RESIZE_MIN_WIDTH, + Math.min(SIDEBAR_RESIZE_MAX_WIDTH, Math.trunc(parsed)), + ); +}; diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index ca416113..82d24c3e 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -1758,7 +1758,7 @@ body[data-ui-version="v2"] .gn-v2-sidebar-shell { body[data-ui-version="v2"] .gn-v2-app-sider { min-width: 232px !important; - max-width: 420px !important; + max-width: min(960px, calc(100vw - 360px)) !important; } body[data-ui-version="v2"] .gn-v2-sidebar-redesign {