diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx index cc413a2c65..2cff23df43 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx @@ -49,10 +49,7 @@ import type { SecondaryPanelTabReorderHandler, } from "./secondaryPanelTab"; import { useEnvironmentDiffFiles } from "@/hooks/queries/environment-queries"; -import { - DEFAULT_CODE_OVERFLOW_MODE, - type CodeOverflowMode, -} from "@/lib/code-overflow-mode"; +import { useGitDiffLineOverflowModePreference } from "@/lib/git-diff-view-preferences"; import type { DiffPresentation } from "@/components/code/code-rendering"; import { useGitDiffPanelState } from "./git-diff/useGitDiffPanelState"; import { useResponsiveGitDiffPanelDisplay } from "./git-diff/useResponsiveGitDiffPanelDisplay"; @@ -267,11 +264,10 @@ export function ThreadSecondaryPanel({ const { gitDiffDisplayMode, handleGitDiffDisplayModeChange, - handleSecondaryPanelResizeStart, handleSecondaryPanelWidthChange, } = useResponsiveGitDiffPanelDisplay({ isSecondaryPanelOpen: isOpen }); const { - handleSecondaryPanelDragging: handleResizeDragging, + handleSecondaryPanelDragging, handleSecondaryPanelResize, handleSecondaryPanelResizePointerDownCapture, persistedWidthPercent, @@ -281,16 +277,6 @@ export function ThreadSecondaryPanel({ isSecondaryPanelOpen: isOpen, onPanelWidthChange: handleSecondaryPanelWidthChange, }); - const handleSecondaryPanelDragging: SecondaryPanelDraggingHandler = - useCallback( - (isDragging) => { - if (isDragging) { - handleSecondaryPanelResizeStart(); - } - handleResizeDragging(isDragging); - }, - [handleResizeDragging, handleSecondaryPanelResizeStart], - ); const hasPanelExpandedRef = useRef(false); useLayoutEffect(() => { hasPanelExpandedRef.current = false; @@ -395,7 +381,7 @@ export function ThreadSecondaryPanel({ ); const [desktopInfo] = useState(getBbDesktopInfo); const [gitDiffLineOverflowMode, setGitDiffLineOverflowMode] = - useState(DEFAULT_CODE_OVERFLOW_MODE); + useGitDiffLineOverflowModePreference(); const usesDesktopChrome = shouldUseMacosDesktopChrome(desktopInfo); const desktopWindowState = useDesktopWindowState(); const isSidebarShowing = useOptionalIsSidebarShowing(); diff --git a/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.test.tsx b/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.test.tsx new file mode 100644 index 0000000000..092f9730d0 --- /dev/null +++ b/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.test.tsx @@ -0,0 +1,228 @@ +// @vitest-environment jsdom + +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, expect, it } from "vitest"; +import { + GIT_DIFF_DISPLAY_MODE_STORAGE_KEY, + GIT_DIFF_LINE_OVERFLOW_MODE_STORAGE_KEY, + useGitDiffLineOverflowModePreference, +} from "@/lib/git-diff-view-preferences"; +import { + resolveGitDiffDisplayMode, + useResponsiveGitDiffPanelDisplay, +} from "./useResponsiveGitDiffPanelDisplay"; + +const NARROW_WIDTH_PX = 500; +const WIDE_WIDTH_PX = 900; + +afterEach(() => { + cleanup(); + window.localStorage.clear(); +}); + +function renderDisplay() { + return renderHook(() => + useResponsiveGitDiffPanelDisplay({ isSecondaryPanelOpen: true }), + ); +} + +function renderCompactDisplay() { + return renderHook( + () => useResponsiveGitDiffPanelDisplay({ isSecondaryPanelOpen: true }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }, + ); +} + +it("follows panel width while no explicit preference is stored", () => { + const { result } = renderDisplay(); + + act(() => { + result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); + }); + expect(result.current.gitDiffDisplayMode).toBe("split"); + + act(() => { + result.current.handleSecondaryPanelWidthChange(NARROW_WIDTH_PX); + }); + expect(result.current.gitDiffDisplayMode).toBe("unified"); + + expect( + window.localStorage.getItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY), + ).toBeNull(); +}); + +it("honors an explicit choice at any panel width", () => { + const { result } = renderDisplay(); + + act(() => { + result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); + }); + act(() => { + result.current.handleGitDiffDisplayModeChange("unified"); + }); + act(() => { + result.current.handleSecondaryPanelWidthChange(NARROW_WIDTH_PX); + }); + act(() => { + result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); + }); + + expect(result.current.gitDiffDisplayMode).toBe("unified"); +}); + +it("keeps a stored split choice on a panel narrower than the breakpoint", () => { + const { result } = renderDisplay(); + + act(() => { + result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); + }); + act(() => { + result.current.handleGitDiffDisplayModeChange("split"); + }); + act(() => { + result.current.handleSecondaryPanelWidthChange(NARROW_WIDTH_PX); + }); + + expect(result.current.gitDiffDisplayMode).toBe("split"); +}); + +it("restores the stored display mode on a fresh mount", () => { + const first = renderDisplay(); + act(() => { + first.result.current.handleSecondaryPanelWidthChange(NARROW_WIDTH_PX); + }); + act(() => { + first.result.current.handleGitDiffDisplayModeChange("split"); + }); + cleanup(); + + const { result } = renderDisplay(); + expect(result.current.gitDiffDisplayMode).toBe("split"); +}); + +it("ignores an unrecognized stored display mode", () => { + window.localStorage.setItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY, "sideways"); + + const { result } = renderDisplay(); + act(() => { + result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); + }); + + expect(result.current.gitDiffDisplayMode).toBe("split"); +}); + +it("does not track width changes while the panel is closed", () => { + const { result } = renderHook(() => + useResponsiveGitDiffPanelDisplay({ isSecondaryPanelOpen: false }), + ); + + act(() => { + result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); + }); + + expect(result.current.gitDiffDisplayMode).toBe("unified"); +}); + +it("defaults a compact viewport to unified even with split stored", () => { + window.localStorage.setItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY, "split"); + + const { result } = renderCompactDisplay(); + + expect(result.current.gitDiffDisplayMode).toBe("unified"); +}); + +it("lets a compact viewport toggle to split", () => { + const { result } = renderCompactDisplay(); + + act(() => { + result.current.handleGitDiffDisplayModeChange("split"); + }); + + expect(result.current.gitDiffDisplayMode).toBe("split"); +}); + +it("does not persist a compact viewport toggle", () => { + const first = renderCompactDisplay(); + act(() => { + first.result.current.handleGitDiffDisplayModeChange("split"); + }); + expect( + window.localStorage.getItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY), + ).toBeNull(); + cleanup(); + + const { result } = renderCompactDisplay(); + expect(result.current.gitDiffDisplayMode).toBe("unified"); +}); + +it("leaves an existing stored preference untouched when toggling on compact", () => { + window.localStorage.setItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY, "split"); + + const compact = renderCompactDisplay(); + act(() => { + compact.result.current.handleGitDiffDisplayModeChange("unified"); + }); + cleanup(); + + expect(window.localStorage.getItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY)).toBe( + "split", + ); + + const { result } = renderDisplay(); + expect(result.current.gitDiffDisplayMode).toBe("split"); +}); + +it("resolves display mode from viewport, preference, and width", () => { + const resolve = ( + isCompactViewport: boolean, + compactDisplayMode: "unified" | "split" | null, + displayModePreference: "unified" | "split" | null, + isWideEnoughForSplit: boolean | null, + ) => + resolveGitDiffDisplayMode({ + isCompactViewport, + compactDisplayMode, + displayModePreference, + isWideEnoughForSplit, + }); + + expect(resolve(true, null, "split", true)).toBe("unified"); + expect(resolve(true, "split", "unified", false)).toBe("split"); + expect(resolve(true, "unified", "split", true)).toBe("unified"); + + expect(resolve(false, null, null, null)).toBe("unified"); + expect(resolve(false, null, null, false)).toBe("unified"); + expect(resolve(false, null, null, true)).toBe("split"); + expect(resolve(false, "split", null, false)).toBe("unified"); + expect(resolve(false, null, "split", false)).toBe("split"); + expect(resolve(false, null, "unified", true)).toBe("unified"); +}); + +it("restores the stored line overflow mode on a fresh mount", () => { + const first = renderHook(() => useGitDiffLineOverflowModePreference()); + act(() => { + first.result.current[1]("wrap"); + }); + expect( + window.localStorage.getItem(GIT_DIFF_LINE_OVERFLOW_MODE_STORAGE_KEY), + ).toBe("wrap"); + cleanup(); + + const { result } = renderHook(() => useGitDiffLineOverflowModePreference()); + expect(result.current[0]).toBe("wrap"); +}); + +it("falls back to scroll for an unrecognized stored line overflow mode", () => { + window.localStorage.setItem(GIT_DIFF_LINE_OVERFLOW_MODE_STORAGE_KEY, "fold"); + + const { result } = renderHook(() => useGitDiffLineOverflowModePreference()); + expect(result.current[0]).toBe("scroll"); +}); diff --git a/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.ts b/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.ts index 0c9d2fb1bb..d46ed02e90 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.ts @@ -1,4 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { useCallback, useState } from "react"; +import { useGitDiffDisplayModePreference } from "@/lib/git-diff-view-preferences"; import type { GitDiffDisplayMode, GitDiffDisplayModeChangeHandler, @@ -7,7 +9,29 @@ import type { SecondaryPanelWidthChangeHandler } from "../useSecondaryPanelResiz const GIT_DIFF_SPLIT_VIEW_MIN_WIDTH_PX = 760; -type SecondaryPanelResizeStartHandler = () => void; +export const COMPACT_GIT_DIFF_DISPLAY_MODE: GitDiffDisplayMode = "unified"; + +interface ResolveGitDiffDisplayModeArgs { + isCompactViewport: boolean; + compactDisplayMode: GitDiffDisplayMode | null; + displayModePreference: GitDiffDisplayMode | null; + isWideEnoughForSplit: boolean | null; +} + +export function resolveGitDiffDisplayMode({ + isCompactViewport, + compactDisplayMode, + displayModePreference, + isWideEnoughForSplit, +}: ResolveGitDiffDisplayModeArgs): GitDiffDisplayMode { + if (isCompactViewport) { + return compactDisplayMode ?? COMPACT_GIT_DIFF_DISPLAY_MODE; + } + if (displayModePreference !== null) { + return displayModePreference; + } + return isWideEnoughForSplit === true ? "split" : "unified"; +} interface UseResponsiveGitDiffPanelDisplayArgs { isSecondaryPanelOpen: boolean; @@ -16,10 +40,14 @@ interface UseResponsiveGitDiffPanelDisplayArgs { export function useResponsiveGitDiffPanelDisplay({ isSecondaryPanelOpen, }: UseResponsiveGitDiffPanelDisplayArgs) { - const [gitDiffDisplayMode, setGitDiffDisplayMode] = - useState("unified"); - const lastDiffViewWideEnoughRef = useRef(null); - const hasExplicitDisplayModeRef = useRef(false); + const isCompactViewport = useIsCompactViewport(); + const [displayModePreference, setDisplayModePreference] = + useGitDiffDisplayModePreference(); + const [compactDisplayMode, setCompactDisplayMode] = + useState(null); + const [isWideEnoughForSplit, setIsWideEnoughForSplit] = useState< + boolean | null + >(null); const handleSecondaryPanelWidthChange = useCallback( @@ -28,52 +56,34 @@ export function useResponsiveGitDiffPanelDisplay({ return; } - const isWideEnough = nextWidth >= GIT_DIFF_SPLIT_VIEW_MIN_WIDTH_PX; - const previousWideEnough = lastDiffViewWideEnoughRef.current; - const crossedBreakpoint = - previousWideEnough !== null && previousWideEnough !== isWideEnough; - - if (crossedBreakpoint && hasExplicitDisplayModeRef.current) { - hasExplicitDisplayModeRef.current = false; - } - - if (!hasExplicitDisplayModeRef.current || crossedBreakpoint) { - const nextMode = isWideEnough ? "split" : "unified"; - setGitDiffDisplayMode((current) => - current === nextMode ? current : nextMode, - ); - } - - lastDiffViewWideEnoughRef.current = isWideEnough; + const nextWideEnough = nextWidth >= GIT_DIFF_SPLIT_VIEW_MIN_WIDTH_PX; + setIsWideEnoughForSplit((current) => + current === nextWideEnough ? current : nextWideEnough, + ); }, - [isSecondaryPanelOpen, setGitDiffDisplayMode], + [isSecondaryPanelOpen], ); - useEffect(() => { - if (!isSecondaryPanelOpen) { - hasExplicitDisplayModeRef.current = false; - lastDiffViewWideEnoughRef.current = null; - } - }, [isSecondaryPanelOpen]); - const handleGitDiffDisplayModeChange = useCallback( (nextMode) => { - hasExplicitDisplayModeRef.current = true; - setGitDiffDisplayMode(nextMode); + if (isCompactViewport) { + setCompactDisplayMode(nextMode); + return; + } + setDisplayModePreference(nextMode); }, - [setGitDiffDisplayMode], + [isCompactViewport, setDisplayModePreference], ); - const handleSecondaryPanelResizeStart = - useCallback(() => { - hasExplicitDisplayModeRef.current = false; - }, []); - return { - gitDiffDisplayMode, + gitDiffDisplayMode: resolveGitDiffDisplayMode({ + isCompactViewport, + compactDisplayMode, + displayModePreference, + isWideEnoughForSplit, + }), handleGitDiffDisplayModeChange, - handleSecondaryPanelResizeStart, handleSecondaryPanelWidthChange, }; } diff --git a/apps/app/src/lib/git-diff-view-preferences.ts b/apps/app/src/lib/git-diff-view-preferences.ts new file mode 100644 index 0000000000..2db79f37bd --- /dev/null +++ b/apps/app/src/lib/git-diff-view-preferences.ts @@ -0,0 +1,47 @@ +import { useAtom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import type { GitDiffDisplayMode } from "@/components/secondary-panel/GitDiffToolbar"; +import { + createLocalStorageEnumStorage, + createNullableLocalStorageEnumStorage, +} from "./browser-storage"; +import { + DEFAULT_CODE_OVERFLOW_MODE, + type CodeOverflowMode, +} from "./code-overflow-mode"; + +export const GIT_DIFF_DISPLAY_MODE_STORAGE_KEY = + "bb.thread.gitDiff.displayMode"; +export const GIT_DIFF_LINE_OVERFLOW_MODE_STORAGE_KEY = + "bb.thread.gitDiff.lineOverflowMode"; + +function isGitDiffDisplayMode(value: string): value is GitDiffDisplayMode { + return value === "unified" || value === "split"; +} + +function isCodeOverflowMode(value: string): value is CodeOverflowMode { + return value === "wrap" || value === "scroll"; +} + +const gitDiffDisplayModePreferenceAtom = + atomWithStorage( + GIT_DIFF_DISPLAY_MODE_STORAGE_KEY, + null, + createNullableLocalStorageEnumStorage(isGitDiffDisplayMode), + { getOnInit: true }, + ); + +const gitDiffLineOverflowModePreferenceAtom = atomWithStorage( + GIT_DIFF_LINE_OVERFLOW_MODE_STORAGE_KEY, + DEFAULT_CODE_OVERFLOW_MODE, + createLocalStorageEnumStorage(isCodeOverflowMode), + { getOnInit: true }, +); + +export function useGitDiffDisplayModePreference() { + return useAtom(gitDiffDisplayModePreferenceAtom); +} + +export function useGitDiffLineOverflowModePreference() { + return useAtom(gitDiffLineOverflowModePreferenceAtom); +}