diff --git a/src/app/globals.css b/src/app/globals.css index e51294cf2b..3680a202aa 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1752,16 +1752,19 @@ } /* Workspace panel show/hide slide. Toggling the left sidebar, right aux panel, - or bottom terminal recomputes the react-resizable-panels layout and snaps - each panel's `flex-grow` (0 ↔ N). FolderWorkspaceShell tags the affected - panel group with `.panel-slide-animating` for the duration of the toggle - only — never during handle drags or container/window resizes — so the - neighboring panes get pushed smoothly instead of jumping. The `>` child - combinator scopes the transition to the group's own panels, so a horizontal - (sidebar/aux) toggle never animates the nested vertical (terminal) group, - and vice versa. */ + or bottom terminal snaps that panel's px `flex-basis` (0 ↔ N — + FolderWorkspaceShell pins those three panels in px over the + react-resizable-panels `flex-grow`, which the center / workspace pane still + carries). FolderWorkspaceShell tags the affected panel group with + `.panel-slide-animating` for the duration of the toggle only — never during + handle drags or container/window resizes — so the neighboring panes get + pushed smoothly instead of jumping. The `>` child combinator scopes the + transition to the group's own panels, so a horizontal (sidebar/aux) toggle + never animates the nested vertical (terminal) group, and vice versa. */ .panel-slide-animating > [data-panel] { - transition: flex-grow 240ms ease-out; + transition: + flex-grow 240ms ease-out, + flex-basis 240ms ease-out; } @media (prefers-reduced-motion: reduce) { diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index 027f17d929..7b2482318b 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -11,6 +11,7 @@ import { useMemo, useCallback, useEffect, + useLayoutEffect, useRef, useState, } from "react" @@ -127,6 +128,14 @@ const DEFAULT_FUSION_LAYOUT: [number, number] = [56, 44] const MIN_CENTER_WIDTH_PX = 420 const MIN_WORKSPACE_HEIGHT_PX = 220 const LAYOUT_EPSILON = 0.25 +// Skip-check for re-applying a pixel-derived layout to the library. The +// columns on screen are pinned in px (see sidebarPanelStyle), but the +// library's percent layout is the base a handle drag (or arrow key) starts +// from, so it has to track those px to well under a pixel: with +// LAYOUT_EPSILON (0.25% ≈ 3.6px at 1440px) here, small resize steps were +// skipped until they added up, and the next drag started that far off the +// divider on screen. +const PIXEL_LAYOUT_EPSILON = 0.01 // Slide duration for panel show/hide; must match the CSS transition on // `.panel-slide-animating > [data-panel]` in globals.css. The transition class // is held a touch past this so the animation finishes before it's removed @@ -144,9 +153,13 @@ function TabKeysSync() { return null } -function isSameLayout(a: number[], b: number[]): boolean { +function isSameLayout( + a: number[], + b: number[], + epsilon: number = LAYOUT_EPSILON +): boolean { if (a.length !== b.length) return false - return a.every((value, index) => Math.abs(value - b[index]) <= LAYOUT_EPSILON) + return a.every((value, index) => Math.abs(value - b[index]) <= epsilon) } function clamp(value: number, min: number, max: number): number { @@ -179,9 +192,10 @@ function resolvePanelSizeRange( * The toggle is detected during render (React's sanctioned "adjust state when a * prop changes" pattern) rather than in an effect: turning the class on in the * render that observes the flip lands it in the same commit as the panel - * resize, so the browser has the transition in place before `flex-grow` - * changes. Turn-off is deferred to a timer keyed on a per-toggle sequence, so a - * fresh toggle mid-slide re-arms the timer instead of inheriting the old one. + * resize, so the browser has the transition in place before the panel's + * `flex-basis` (or `flex-grow`) changes. Turn-off is deferred to a timer keyed + * on a per-toggle sequence, so a fresh toggle mid-slide re-arms the timer + * instead of inheriting the old one. */ function usePanelSlideOnToggle(open: boolean, ready: boolean): boolean { const [animating, setAnimating] = useState(false) @@ -766,23 +780,59 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { const [shellWidth, setShellWidth] = useState(0) const [mainHeight, setMainHeight] = useState(0) + // The panels split the width LEFT OVER by the dividers, not the container's: + // an open divider is a fixed 1px flex item (a closed one is `w-0`), and the + // panels' percentages are shares of the remainder. Converting px <-> percent + // against the full container width left the side columns up to a pixel off + // their requested width, by an amount that changes with every window width — + // the divider crept sub-pixel on each resize step. + const shellHandlesWidth = (sidebarOpen ? 1 : 0) + (auxOpen ? 1 : 0) + const shellPanelsWidth = + shellWidth > 0 ? Math.max(1, shellWidth - shellHandlesWidth) : 0 + const mainHandlesHeight = terminalOpen ? 1 : 0 + const mainPanelsHeight = + mainHeight > 0 ? Math.max(1, mainHeight - mainHandlesHeight) : 0 const shellDesiredLayoutRef = useRef<[number, number, number]>([0, 100, 0]) const shellAppliedLayoutRef = useRef<[number, number, number] | null>(null) const mainDesiredLayoutRef = useRef<[number, number]>([100, 0]) const mainAppliedLayoutRef = useRef<[number, number] | null>(null) + // A window resize needs no JS to keep the panes in place: the side columns + // and the terminal are sized in px through CSS (see the panel styles below) + // and the center / workspace pane flexes. The container size is only tracked + // to keep react-resizable-panels' percent layout and min/max in step (and to + // shrink the side columns once the window is too narrow for them), so an + // ordinary state update is enough: no frame depends on it being flushed + // synchronously, and none has to wait for a re-render of the shell. + // + // Each container size is held twice: as last observed, and as last applied + // to the library's layout (by the layout effects below). While the two + // differ, a container resize is on its way to that layout effect, and the + // onLayout calls in between (the library re-clamping the panels against the + // new min/max percentages, then our own setLayout) aren't user resizes and + // must not be persisted — the re-clamp reports through an onLayout still + // holding the old container size. Comparing sizes, rather than raising a + // flag in the observer for the layout effect to lower, also holds when two + // observations cancel out before React renders: that render bails out with + // no layout effect run, and a flag left raised would swallow every later + // drag until the next resize. + const shellWidthRef = useRef(0) + const shellAppliedWidthRef = useRef(0) + const mainHeightRef = useRef(0) + const mainAppliedHeightRef = useRef(0) + useEffect(() => { const container = shellContainerRef.current if (!container) return - const updateWidth = (next: number) => { - setShellWidth((prev) => (Math.abs(prev - next) < 1 ? prev : next)) - } - - updateWidth(container.clientWidth) + shellWidthRef.current = container.clientWidth + setShellWidth(container.clientWidth) const observer = new ResizeObserver((entries) => { - updateWidth(entries[0]?.contentRect.width ?? container.clientWidth) + const next = entries[0]?.contentRect.width ?? container.clientWidth + if (Math.abs(shellWidthRef.current - next) < 1) return + shellWidthRef.current = next + setShellWidth(next) }) observer.observe(container) @@ -795,13 +845,13 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { const container = mainContainerRef.current if (!container) return - const updateHeight = (next: number) => { - setMainHeight((prev) => (Math.abs(prev - next) < 1 ? prev : next)) - } - - updateHeight(container.clientHeight) + mainHeightRef.current = container.clientHeight + setMainHeight(container.clientHeight) const observer = new ResizeObserver((entries) => { - updateHeight(entries[0]?.contentRect.height ?? container.clientHeight) + const next = entries[0]?.contentRect.height ?? container.clientHeight + if (Math.abs(mainHeightRef.current - next) < 1) return + mainHeightRef.current = next + setMainHeight(next) }) observer.observe(container) @@ -810,7 +860,16 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { } }, []) - const buildShellLayout = useCallback((): [number, number, number] => { + // The side columns' widths in px: the requested widths, scaled down together + // when the window is too narrow to also leave MIN_CENTER_WIDTH_PX between — + // but never below a column's own minimum. These px are what is drawn, and + // the library clamps its own layout to the panels' minSize, so they have to + // land where that clamp would: below it, a column renders narrower than its + // content allows (the aux minimum on Windows/Linux is what keeps the + // title-bar overlay off the center column), and the library's layout — the + // base a handle drag starts from — sits tens of px away from the divider on + // screen. + const shellSides = useMemo(() => { const requestedLeft = sidebarOpen ? clamp(sidebarWidth, sidebarMinWidth, sidebarMaxWidth) : 0 @@ -819,7 +878,9 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { : 0 const totalWidth = - shellWidth > 0 ? shellWidth : requestedLeft + requestedRight + 960 + shellPanelsWidth > 0 + ? shellPanelsWidth + : requestedLeft + requestedRight + 960 let left = requestedLeft let right = requestedRight @@ -830,28 +891,71 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { const scale = maxSideTotal / sideTotal left *= scale right *= scale - } - const center = Math.max(1, totalWidth - left - right) - const total = left + center + right + // Back up to each open column's minimum the way the library's clamp + // does it (validatePanelGroupLayout): a panel raised to its minSize + // takes the shortfall from the first panel in order that can spare it — + // the sidebar, down to its own minimum, and then the center. + let shortfall = 0 + if (sidebarOpen && left < sidebarMinWidth) { + shortfall += sidebarMinWidth - left + left = sidebarMinWidth + } + if (auxOpen && right < auxMinWidth) { + shortfall += auxMinWidth - right + right = auxMinWidth + } + if (sidebarOpen && shortfall > 0) { + left -= Math.min(shortfall, left - sidebarMinWidth) + } + } - return [(left / total) * 100, (center / total) * 100, (right / total) * 100] + return { left, right, totalWidth } }, [ auxMaxWidth, auxMinWidth, auxOpen, auxWidth, - shellWidth, + shellPanelsWidth, sidebarMaxWidth, sidebarMinWidth, sidebarOpen, sidebarWidth, ]) - const buildMainLayout = useCallback((): [number, number] => { - if (!terminalOpen) { - return [100, 0] - } + const buildShellLayout = useCallback((): [number, number, number] => { + const { left, right, totalWidth } = shellSides + const center = Math.max(1, totalWidth - left - right) + const total = left + center + right + + return [(left / total) * 100, (center / total) * 100, (right / total) * 100] + }, [shellSides]) + + // The side panels are sized in px straight through CSS, overriding the + // `flex-grow: ` react-resizable-panels writes (a Panel's `style` + // prop wins over its computed one), and the center — the only panel left + // growing — takes whatever remains. That style is rounded to 3 significant + // digits (`toPrecision(3)`, e.g. 20.83% → "20.8"): ~0.5px at 1440px wide, by + // an error that changes with every window width, so a sidebar sized through + // it wobbled on each resize step however precise the layout fed in. Pinned + // in px, a window resize leaves the side columns alone and only the center + // flexes. The library's layout is still kept in step (buildShellLayout) — it + // drives handle drags and the min/max clamping, and a drag reaches these px + // through handleShellLayout → setSidebarWidth / setAuxWidth. + const sidebarPanelStyle = useMemo( + () => ({ flexGrow: 0, flexShrink: 0, flexBasis: `${shellSides.left}px` }), + [shellSides.left] + ) + const auxPanelStyle = useMemo( + () => ({ flexGrow: 0, flexShrink: 0, flexBasis: `${shellSides.right}px` }), + [shellSides.right] + ) + + // The terminal's height in px: the requested height, capped so the workspace + // above keeps MIN_WORKSPACE_HEIGHT_PX — but, like the side columns, never + // below the panel's own minimum, where the library's layout clamps it. + const terminalSize = useMemo(() => { + if (!terminalOpen) return { terminal: 0, totalHeight: 0 } const requestedTerminalHeight = clamp( terminalHeight, @@ -859,27 +963,50 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { terminalMaxHeight ) const totalHeight = - mainHeight > 0 ? mainHeight : requestedTerminalHeight + 640 + mainPanelsHeight > 0 ? mainPanelsHeight : requestedTerminalHeight + 640 const maxTerminalHeight = Math.max(0, totalHeight - MIN_WORKSPACE_HEIGHT_PX) - const terminal = Math.min(requestedTerminalHeight, maxTerminalHeight) - const workspace = Math.max(1, totalHeight - terminal) - const total = workspace + terminal - - return [(workspace / total) * 100, (terminal / total) * 100] + const terminal = Math.max( + Math.min(requestedTerminalHeight, maxTerminalHeight), + terminalMinHeight + ) + return { terminal, totalHeight } }, [ - mainHeight, + mainPanelsHeight, terminalHeight, terminalMaxHeight, terminalMinHeight, terminalOpen, ]) + const buildMainLayout = useCallback((): [number, number] => { + if (!terminalOpen) { + return [100, 0] + } + + const { terminal, totalHeight } = terminalSize + const workspace = Math.max(1, totalHeight - terminal) + const total = workspace + terminal + + return [(workspace / total) * 100, (terminal / total) * 100] + }, [terminalOpen, terminalSize]) + + // Same px pinning as the side columns (see sidebarPanelStyle), so a window + // height change leaves the terminal alone and only the workspace flexes. + const terminalPanelStyle = useMemo( + () => ({ + flexGrow: 0, + flexShrink: 0, + flexBasis: `${terminalSize.terminal}px`, + }), + [terminalSize.terminal] + ) + const applyShellLayout = useCallback((layout: [number, number, number]) => { shellDesiredLayoutRef.current = layout if ( shellAppliedLayoutRef.current && - isSameLayout(shellAppliedLayoutRef.current, layout) + isSameLayout(shellAppliedLayoutRef.current, layout, PIXEL_LAYOUT_EPSILON) ) { return } @@ -899,7 +1026,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { mainDesiredLayoutRef.current = layout if ( mainAppliedLayoutRef.current && - isSameLayout(mainAppliedLayoutRef.current, layout) + isSameLayout(mainAppliedLayoutRef.current, layout, PIXEL_LAYOUT_EPSILON) ) { return } @@ -915,13 +1042,19 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { } }, []) - useEffect(() => { + // Layout effects so the library catches up in the same commit as the new + // container size. Recording the size the layout was applied for, AFTER the + // apply (whose own onLayout must still read as resize-caused), ends the + // resize window (see the ResizeObserver note). + useLayoutEffect(() => { applyShellLayout(buildShellLayout()) - }, [applyShellLayout, buildShellLayout]) + shellAppliedWidthRef.current = shellWidth + }, [applyShellLayout, buildShellLayout, shellWidth]) - useEffect(() => { + useLayoutEffect(() => { applyMainLayout(buildMainLayout()) - }, [applyMainLayout, buildMainLayout]) + mainAppliedHeightRef.current = mainHeight + }, [applyMainLayout, buildMainLayout, mainHeight]) const handleShellLayout = useCallback( (layout: number[]) => { @@ -947,10 +1080,14 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { return } - if (shellWidth <= 0) return + if ( + shellWidthRef.current !== shellAppliedWidthRef.current || + shellPanelsWidth <= 0 + ) + return if (sidebarOpen) { - const nextSidebarWidth = (normalizedLayout[0] / 100) * shellWidth + const nextSidebarWidth = (normalizedLayout[0] / 100) * shellPanelsWidth const withinSidebarRange = nextSidebarWidth >= sidebarMinWidth - 1 && nextSidebarWidth <= sidebarMaxWidth + 1 @@ -963,7 +1100,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { } if (auxOpen) { - const nextAuxWidth = (normalizedLayout[2] / 100) * shellWidth + const nextAuxWidth = (normalizedLayout[2] / 100) * shellPanelsWidth const withinAuxRange = nextAuxWidth >= auxMinWidth - 1 && nextAuxWidth <= auxMaxWidth + 1 if (withinAuxRange && Math.abs(nextAuxWidth - auxWidth) >= 1) { @@ -979,7 +1116,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { auxWidth, setAuxWidth, setSidebarWidth, - shellWidth, + shellPanelsWidth, sidebarMaxWidth, sidebarMinWidth, sidebarOpen, @@ -1004,9 +1141,14 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { return } - if (!terminalOpen || mainHeight <= 0) return + if ( + !terminalOpen || + mainHeightRef.current !== mainAppliedHeightRef.current || + mainPanelsHeight <= 0 + ) + return - const nextTerminalHeight = (normalizedLayout[1] / 100) * mainHeight + const nextTerminalHeight = (normalizedLayout[1] / 100) * mainPanelsHeight const withinTerminalRange = nextTerminalHeight >= terminalMinHeight - 1 && nextTerminalHeight <= terminalMaxHeight + 1 @@ -1019,7 +1161,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { }, [ applyMainLayout, - mainHeight, + mainPanelsHeight, setTerminalHeight, terminalHeight, terminalMaxHeight, @@ -1028,7 +1170,20 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { ] ) - const safeShellWidth = shellWidth > 0 ? shellWidth : 1440 + // The panes' contents as stable elements: the shell re-renders on every + // container resize step (and on each drag step), and none of these read + // anything from it — they take their state from context — so React bails + // out of re-rendering their (large) subtrees instead of redoing the whole + // workspace each frame. + const sidebarElement = useMemo(() => , []) + const auxElement = useMemo(() => , []) + const terminalElement = useMemo(() => , []) + const workspaceElement = useMemo( + () => {children}, + [children] + ) + + const safeShellWidth = shellPanelsWidth > 0 ? shellPanelsWidth : 1440 const sidebarSizeRange = resolvePanelSizeRange( sidebarMinWidth, sidebarMaxWidth, @@ -1040,7 +1195,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { safeShellWidth ) - const safeMainHeight = mainHeight > 0 ? mainHeight : 900 + const safeMainHeight = mainPanelsHeight > 0 ? mainPanelsHeight : 900 const terminalSizeRange = resolvePanelSizeRange( terminalMinHeight, terminalMaxHeight, @@ -1063,6 +1218,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { id={FOLDER_SHELL_LEFT_PANEL_ID} order={1} defaultSize={18} + style={sidebarPanelStyle} minSize={sidebarOpen ? sidebarSizeRange.minSize : 0} maxSize={sidebarOpen ? sidebarSizeRange.maxSize : 0} > @@ -1072,7 +1228,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { slide — an un-backed wrapper would show the root `bg-background` (white) through that gap. */}
- + {sidebarElement}
@@ -1109,7 +1265,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { defaultSize={72} minSize={15} > - {children} + {workspaceElement} {/* Closed, the handle gives up its BOX, not just its paint — and @@ -1138,11 +1294,12 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { id={FOLDER_MAIN_TERMINAL_PANEL_ID} order={2} defaultSize={28} + style={terminalPanelStyle} minSize={terminalOpen ? terminalSizeRange.minSize : 0} maxSize={terminalOpen ? terminalSizeRange.maxSize : 0} >
- + {terminalElement}
@@ -1161,6 +1318,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { id={FOLDER_SHELL_RIGHT_PANEL_ID} order={3} defaultSize={18} + style={auxPanelStyle} minSize={auxOpen ? auxSizeRange.minSize : 0} maxSize={auxOpen ? auxSizeRange.maxSize : 0} > @@ -1172,7 +1330,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { to the old opaque wrapper, so the 240ms collapse slide still never flashes white while AuxPanel `return null`s. */}
- + {auxElement}