From fced746a27ae210460ea779a876b6144ed886483 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Nov 2026 03:52:18 +0100 Subject: [PATCH] fix(app): retain Explorer resize width until acknowledged Co-authored-by: Elsa Granger --- .../explorer-sidebar-layout.test.ts | 120 ++++++++++++++++++ .../src/components/explorer-sidebar-layout.ts | 55 ++++++++ .../workspace/explorer-sidebar/geometry.tsx | 81 +++++++++--- 3 files changed, 240 insertions(+), 16 deletions(-) diff --git a/packages/app/src/components/explorer-sidebar-layout.test.ts b/packages/app/src/components/explorer-sidebar-layout.test.ts index 3681ad4e16..8e4c09c003 100644 --- a/packages/app/src/components/explorer-sidebar-layout.test.ts +++ b/packages/app/src/components/explorer-sidebar-layout.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import { + finishExplorerSidebarResize, + reconcileExplorerSidebarResize, resolveExplorerSidebarDockSizes, + resolveExplorerSidebarFrameWidth, resolveExplorerSidebarWidth, + type ExplorerSidebarResize, } from "@/components/explorer-sidebar-layout"; describe("Explorer sidebar layout", () => { @@ -19,4 +23,120 @@ describe("Explorer sidebar layout", () => { expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(840); expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(390); }); + + describe("native resize ownership", () => { + const allocation = { + width: 320, + containerWidth: 1200, + overlay: false, + resizeEnabled: true, + }; + const dragging: ExplorerSidebarResize = { + sourceWidth: allocation.width, + containerWidth: allocation.containerWidth, + pendingWidth: null, + }; + + it("keeps the drag width after release while the JS commit still has the old prop", () => { + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: dragging, resizeWidth: 480 }), + ).toBe(480); + + const released = finishExplorerSidebarResize(dragging, 480, true); + expect(reconcileExplorerSidebarResize(released, allocation)).toBe(released); + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 480 }), + ).toBe(480); + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 320 }), + ).toBe(480); + }); + + it("acknowledges the committed width and immediately follows subsequent props", () => { + const released = finishExplorerSidebarResize(dragging, 480, true); + const acknowledgedAllocation = { ...allocation, width: 480 }; + expect( + resolveExplorerSidebarFrameWidth({ + ...acknowledgedAllocation, + resize: released, + resizeWidth: 480, + }), + ).toBe(480); + const acknowledged = reconcileExplorerSidebarResize(released, acknowledgedAllocation); + expect(acknowledged).toBeNull(); + expect( + resolveExplorerSidebarFrameWidth({ + ...allocation, + width: 360, + resize: acknowledged, + resizeWidth: 480, + }), + ).toBe(360); + }); + + it.each([ + { name: "a new width prop", next: { ...allocation, width: 360 }, expected: 360 }, + { + name: "a window allocation with an unchanged width prop", + next: { ...allocation, containerWidth: 900 }, + expected: 320, + }, + { + name: "a window allocation that clamps the width prop", + next: { ...allocation, width: 540, containerWidth: 900 }, + expected: 540, + }, + { name: "overlay presentation", next: { ...allocation, overlay: true }, expected: 1200 }, + { name: "an inactive dock", next: { ...allocation, resizeEnabled: false }, expected: 320 }, + ])( + "yields synchronously to $name and does not resurrect the old handoff", + ({ next, expected }) => { + const released = finishExplorerSidebarResize(dragging, 600, true); + expect( + resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 600 }), + ).toBe(expected); + const interrupted = reconcileExplorerSidebarResize(released, next); + expect(interrupted).toBeNull(); + expect( + resolveExplorerSidebarFrameWidth({ + ...allocation, + resize: interrupted, + resizeWidth: 600, + }), + ).toBe(320); + }, + ); + + it("restores canonical intent when a drag is canceled", () => { + const canceled = finishExplorerSidebarResize(dragging, 480, false); + expect(canceled).toBeNull(); + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 480 }), + ).toBe(320); + }); + + it("cannot finish an interrupted drag over a newer allocation", () => { + const next = { ...allocation, width: 360, containerWidth: 1000 }; + const interrupted = reconcileExplorerSidebarResize(dragging, next); + const released = finishExplorerSidebarResize(interrupted, 480, true); + expect(released).toBeNull(); + expect( + resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 480 }), + ).toBe(360); + }); + + it("settles a release at the already-committed width without retaining ownership", () => { + const released = finishExplorerSidebarResize(dragging, allocation.width, true); + expect(reconcileExplorerSidebarResize(released, allocation)).toBeNull(); + }); + + it("keeps an earlier released handoff when a later touch fails before activation", () => { + const released = finishExplorerSidebarResize(dragging, 480, true); + const finalized = finishExplorerSidebarResize(released, 480, false); + expect(finalized).toBe(released); + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: finalized, resizeWidth: 480 }), + ).toBe(480); + }); + }); }); diff --git a/packages/app/src/components/explorer-sidebar-layout.ts b/packages/app/src/components/explorer-sidebar-layout.ts index 8a39e2e9f4..4b00845076 100644 --- a/packages/app/src/components/explorer-sidebar-layout.ts +++ b/packages/app/src/components/explorer-sidebar-layout.ts @@ -16,6 +16,61 @@ export function resolveExplorerSidebarWidth(input: { return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth)); } +// A release keeps its UI width until React acknowledges it or replaces its allocation. +// Null pendingWidth means the gesture still owns the live resizeWidth instead. +export interface ExplorerSidebarResize { + sourceWidth: number; + containerWidth: number; + pendingWidth: number | null; +} + +interface ExplorerSidebarAllocation { + width: number; + containerWidth: number; + overlay: boolean; + resizeEnabled: boolean; +} + +export function reconcileExplorerSidebarResize( + resize: ExplorerSidebarResize | null, + allocation: ExplorerSidebarAllocation, +): ExplorerSidebarResize | null { + "worklet"; + if ( + !resize || + allocation.overlay || + !allocation.resizeEnabled || + allocation.containerWidth !== resize.containerWidth || + allocation.width !== resize.sourceWidth || + allocation.width === resize.pendingWidth + ) { + return null; + } + return resize; +} + +export function finishExplorerSidebarResize( + resize: ExplorerSidebarResize | null, + width: number, + success: boolean, +): ExplorerSidebarResize | null { + "worklet"; + if (!resize || resize.pendingWidth !== null) return resize; + return success ? { ...resize, pendingWidth: width } : null; +} + +export function resolveExplorerSidebarFrameWidth( + allocation: ExplorerSidebarAllocation & { + resize: ExplorerSidebarResize | null; + resizeWidth: number; + }, +): number { + "worklet"; + if (allocation.overlay) return allocation.containerWidth; + const resize = reconcileExplorerSidebarResize(allocation.resize, allocation); + return resize ? (resize.pendingWidth ?? allocation.resizeWidth) : allocation.width; +} + export function resolveExplorerSidebarDockSizes(input: { requestedWidth?: number; containerWidth: number; diff --git a/packages/app/src/screens/workspace/explorer-sidebar/geometry.tsx b/packages/app/src/screens/workspace/explorer-sidebar/geometry.tsx index 4714467985..4f04e7d8cc 100644 --- a/packages/app/src/screens/workspace/explorer-sidebar/geometry.tsx +++ b/packages/app/src/screens/workspace/explorer-sidebar/geometry.tsx @@ -1,6 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Gesture } from "react-native-gesture-handler"; -import { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import { + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, +} from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; @@ -9,7 +14,13 @@ import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET, } from "@/components/sidebar-resize-handle-layout"; -import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout"; +import { + finishExplorerSidebarResize, + reconcileExplorerSidebarResize, + resolveExplorerSidebarFrameWidth, + resolveExplorerSidebarWidth, + type ExplorerSidebarResize, +} from "@/components/explorer-sidebar-layout"; import type { ExplorerGeometryInput, ExplorerGeometry } from "./geometry-types"; export function useExplorerGeometry({ width, @@ -21,10 +32,16 @@ export function useExplorerGeometry({ const insets = useSafeAreaInsets(); const resizeWidth = useSharedValue(width); const startWidth = useSharedValue(width); + const resize = useSharedValue(null); const [resizePressed, setResizePressed] = useState(false); - useEffect(() => { - resizeWidth.value = width; - }, [resizeWidth, width]); + useAnimatedReaction( + () => ({ resize: resize.value, width, containerWidth, overlay, resizeEnabled }), + ({ resize: currentResize, ...allocation }) => { + const nextResize = reconcileExplorerSidebarResize(currentResize, allocation); + if (nextResize !== currentResize) resize.value = nextResize; + }, + [containerWidth, overlay, resize, resizeEnabled, width], + ); const showResizeGrip = useCallback(() => setResizePressed(true), []); const hideResizeGrip = useCallback(() => setResizePressed(false), []); @@ -37,22 +54,45 @@ export function useExplorerGeometry({ .activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET]) .failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]) .onStart((event) => { - startWidth.value = width + event.translationX; - resizeWidth.value = width; + const currentWidth = resolveExplorerSidebarFrameWidth({ + width, + containerWidth, + overlay, + resizeEnabled, + resize: resize.value, + resizeWidth: resizeWidth.value, + }); + resize.value = { sourceWidth: width, containerWidth, pendingWidth: null }; + startWidth.value = currentWidth + event.translationX; + resizeWidth.value = currentWidth; }) .onUpdate((event) => { + if (!resize.value) return; resizeWidth.value = resolveExplorerSidebarWidth({ requestedWidth: startWidth.value - event.translationX, containerWidth, }); }) - .onEnd(() => runOnJS(onResize)(resizeWidth.value)) - .onFinalize(() => scheduleOnRN(hideResizeGrip)), + .onEnd((_event, success) => { + { + resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, success); + if (!success || !resize.value) return; + } + runOnJS(onResize)(resizeWidth.value); + }) + .onFinalize((_event, success) => { + if (!success) { + resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, false); + } + scheduleOnRN(hideResizeGrip); + }), [ resizeEnabled, onResize, containerWidth, hideResizeGrip, + overlay, + resize, resizeWidth, showResizeGrip, startWidth, @@ -61,12 +101,21 @@ export function useExplorerGeometry({ ); // Reanimated retains the last animated value when its style is removed. Keep this // style attached across folding and explicitly size overlays to the workspace viewport. - const animatedWidthStyle = useAnimatedStyle( - () => ({ - width: overlay ? containerWidth : resizeWidth.value, - }), - [containerWidth, overlay], - ); + const animatedWidthStyle = useAnimatedStyle(() => { + if (overlay) return { width: containerWidth }; + { + return { + width: resolveExplorerSidebarFrameWidth({ + width, + containerWidth, + overlay, + resizeEnabled, + resize: resize.value, + resizeWidth: resizeWidth.value, + }), + }; + } + }, [containerWidth, overlay, resizeEnabled, width]); return { frameStyle: animatedWidthStyle, panelStyle: {