From 2cfe8a610238c299d67cce762cd66bc9e43f10a3 Mon Sep 17 00:00:00 2001 From: Kun Date: Thu, 8 Oct 2026 23:50:11 +0800 Subject: [PATCH 1/2] fix(layout): pin shell side panels to px so a window resize can't wobble them react-resizable-panels writes each panel's size as `flex-grow` rounded to 3 significant digits (20.83% -> "20.8", ~0.5px at 1440px wide), by an error that changes with every window width. A sidebar sized through it wobbled sub-pixel on each resize step, however precisely the layout was fed in, so the divider jittered while the window was dragged. - Size the sidebar and aux panels (and the terminal, vertically) with a px `flex-basis` through the Panel `style` prop, which overrides the library's computed style, and let the center / workspace pane take the remainder. The library layout stays in step for handle drags and min/max clamping, and a drag reaches the px values through the existing onLayout -> setWidth / setHeight path. - Convert px <-> percent against the space left after the 1px dividers, not the container width, and compare re-applied layouts at sub-pixel precision (the old 0.25% tolerance skipped small steps until they added up, then snapped). - Apply the layout in layout effects, and ignore the onLayout calls a container resize itself causes, so they are not persisted as a user resize. - Keep the pane contents (sidebar, aux panel, terminal, workspace) as stable elements so the shell re-rendering on each resize step skips their subtrees. - The slide transition on the panels now covers `flex-basis` as well, since that is what changes when a side panel opens or closes. Co-Authored-By: Claude Sonnet 5.5 --- src/app/globals.css | 6 +- src/app/workspace/layout.tsx | 213 +++++++++++++++++++++++++++-------- 2 files changed, 170 insertions(+), 49 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index e51294cf2b..232acb0321 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1760,8 +1760,12 @@ 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. */ +/* `flex-basis` too: the shell's side panels are sized by a px basis (see + FolderWorkspaceShell), so that is what changes when they open and close. */ .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..5165ed3153 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,13 @@ 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 shell layout. It must stay well +// under one pixel: the panels are sized in percent, so a window resize keeps +// the old percentages and only a re-applied layout pins the side columns back +// to their pixel width. With LAYOUT_EPSILON (0.25% ≈ 3.6px at 1440px) here, +// small resize steps were skipped until they added up, and the sidebar +// divider drifted with the window and then snapped back — a visible jitter. +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 +152,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 { @@ -766,23 +778,53 @@ 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, 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. + // + // These flags cover the stretch from a container resize to the layout effect + // that applies the resulting layout: 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. + const shellWidthRef = useRef(0) + const mainHeightRef = useRef(0) + const shellContainerResizingRef = useRef(false) + const mainContainerResizingRef = useRef(false) + 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 + shellContainerResizingRef.current = true + setShellWidth(next) }) observer.observe(container) @@ -795,13 +837,14 @@ 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 + mainContainerResizingRef.current = true + setMainHeight(next) }) observer.observe(container) @@ -810,7 +853,9 @@ 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. + const shellSides = useMemo(() => { const requestedLeft = sidebarOpen ? clamp(sidebarWidth, sidebarMinWidth, sidebarMaxWidth) : 0 @@ -819,7 +864,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 @@ -832,26 +879,51 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { right *= scale } - const center = Math.max(1, totalWidth - left - right) - const total = left + center + right - - 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. + const terminalSize = useMemo(() => { + if (!terminalOpen) return { terminal: 0, totalHeight: 0 } const requestedTerminalHeight = clamp( terminalHeight, @@ -859,27 +931,47 @@ 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] + 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 +991,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,12 +1007,16 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { } }, []) - useEffect(() => { + // Layout effects so the library catches up in the same commit as the new + // container size; the resize flags end here (see the ResizeObserver note). + useLayoutEffect(() => { applyShellLayout(buildShellLayout()) + shellContainerResizingRef.current = false }, [applyShellLayout, buildShellLayout]) - useEffect(() => { + useLayoutEffect(() => { applyMainLayout(buildMainLayout()) + mainContainerResizingRef.current = false }, [applyMainLayout, buildMainLayout]) const handleShellLayout = useCallback( @@ -947,10 +1043,10 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { return } - if (shellWidth <= 0) return + if (shellContainerResizingRef.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 +1059,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 +1075,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { auxWidth, setAuxWidth, setSidebarWidth, - shellWidth, + shellPanelsWidth, sidebarMaxWidth, sidebarMinWidth, sidebarOpen, @@ -1004,9 +1100,14 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { return } - if (!terminalOpen || mainHeight <= 0) return + if ( + !terminalOpen || + mainContainerResizingRef.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 +1120,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { }, [ applyMainLayout, - mainHeight, + mainPanelsHeight, setTerminalHeight, terminalHeight, terminalMaxHeight, @@ -1028,7 +1129,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 +1154,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 +1177,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 +1187,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 +1224,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 +1253,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 +1277,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 +1289,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}
From 135fa527f1748d1f2751c9d50437cf6073334faf Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 9 Oct 2026 14:17:14 +0800 Subject: [PATCH 2/2] fix(layout): keep pinned shell panels at their minimums and unstick the resize guard - The side columns scaled down for a narrow window could drop below their minimum widths (and the terminal below its minimum height), where react-resizable-panels clamps its own layout. The drawn panels then disagreed with the layout a handle drag starts from by up to ~100px, and the aux panel could get narrower than the width that keeps the Windows/Linux title-bar overlay off the center column. Raise each open column back to its minimum and take the difference the way the library's clamp does: from the sidebar down to its own minimum, then from the center. - The container-resize guard was a flag raised in the ResizeObserver and lowered by the layout effect that applies the new layout. Two observations that cancel out before React renders make that render bail out with no layout effect, leaving the flag raised, so every later drag was dropped until the next window resize. Compare the last observed container size with the size the layout was last applied for instead. The first measurement now counts as a resize too, so applying it no longer persists scaled-down widths. - Update the comments that still described percent-sized panels and a flex-grow-only slide. --- src/app/globals.css | 19 ++++--- src/app/workspace/layout.tsx | 99 +++++++++++++++++++++++++----------- 2 files changed, 79 insertions(+), 39 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 232acb0321..3680a202aa 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1752,16 +1752,15 @@ } /* 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. */ -/* `flex-basis` too: the shell's side panels are sized by a px basis (see - FolderWorkspaceShell), so that is what changes when they open and close. */ + 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, diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index 5165ed3153..7b2482318b 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -128,12 +128,13 @@ 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 shell layout. It must stay well -// under one pixel: the panels are sized in percent, so a window resize keeps -// the old percentages and only a re-applied layout pins the side columns back -// to their pixel width. With LAYOUT_EPSILON (0.25% ≈ 3.6px at 1440px) here, -// small resize steps were skipped until they added up, and the sidebar -// divider drifted with the window and then snapped back — a visible jitter. +// 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 @@ -191,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) @@ -799,19 +801,26 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { // 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, so an + // 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. // - // These flags cover the stretch from a container resize to the layout effect - // that applies the resulting layout: 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. + // 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 shellContainerResizingRef = useRef(false) - const mainContainerResizingRef = useRef(false) + const mainAppliedHeightRef = useRef(0) useEffect(() => { const container = shellContainerRef.current @@ -823,7 +832,6 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { const next = entries[0]?.contentRect.width ?? container.clientWidth if (Math.abs(shellWidthRef.current - next) < 1) return shellWidthRef.current = next - shellContainerResizingRef.current = true setShellWidth(next) }) @@ -843,7 +851,6 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { const next = entries[0]?.contentRect.height ?? container.clientHeight if (Math.abs(mainHeightRef.current - next) < 1) return mainHeightRef.current = next - mainContainerResizingRef.current = true setMainHeight(next) }) @@ -854,7 +861,14 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { }, []) // 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. + // 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) @@ -877,6 +891,23 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { const scale = maxSideTotal / sideTotal left *= scale right *= scale + + // 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, right, totalWidth } @@ -921,7 +952,8 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { ) // The terminal's height in px: the requested height, capped so the workspace - // above keeps MIN_WORKSPACE_HEIGHT_PX. + // 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 } @@ -934,7 +966,10 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { mainPanelsHeight > 0 ? mainPanelsHeight : requestedTerminalHeight + 640 const maxTerminalHeight = Math.max(0, totalHeight - MIN_WORKSPACE_HEIGHT_PX) - const terminal = Math.min(requestedTerminalHeight, maxTerminalHeight) + const terminal = Math.max( + Math.min(requestedTerminalHeight, maxTerminalHeight), + terminalMinHeight + ) return { terminal, totalHeight } }, [ mainPanelsHeight, @@ -1008,16 +1043,18 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { }, []) // Layout effects so the library catches up in the same commit as the new - // container size; the resize flags end here (see the ResizeObserver note). + // 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()) - shellContainerResizingRef.current = false - }, [applyShellLayout, buildShellLayout]) + shellAppliedWidthRef.current = shellWidth + }, [applyShellLayout, buildShellLayout, shellWidth]) useLayoutEffect(() => { applyMainLayout(buildMainLayout()) - mainContainerResizingRef.current = false - }, [applyMainLayout, buildMainLayout]) + mainAppliedHeightRef.current = mainHeight + }, [applyMainLayout, buildMainLayout, mainHeight]) const handleShellLayout = useCallback( (layout: number[]) => { @@ -1043,7 +1080,11 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { return } - if (shellContainerResizingRef.current || shellPanelsWidth <= 0) return + if ( + shellWidthRef.current !== shellAppliedWidthRef.current || + shellPanelsWidth <= 0 + ) + return if (sidebarOpen) { const nextSidebarWidth = (normalizedLayout[0] / 100) * shellPanelsWidth @@ -1102,7 +1143,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { if ( !terminalOpen || - mainContainerResizingRef.current || + mainHeightRef.current !== mainAppliedHeightRef.current || mainPanelsHeight <= 0 ) return