diff --git a/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx b/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx index 10064655a3..6aa6f4e507 100644 --- a/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx +++ b/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx @@ -33,6 +33,13 @@ const TABS: readonly SidebarSplitTabDescriptor[] = [ const PANEL_STATE_ID = "sidebar-split-container-test"; let nextPaneInstance = 0; +function listPaneIds(): string[] { + return Array.from( + document.querySelectorAll("[data-split-pane-id]"), + (pane) => pane.dataset.splitPaneId, + ).filter((paneId): paneId is string => paneId !== undefined); +} + function createTwoPaneState(): SidebarSplitState { const initial = createSidebarSplitState( TABS.map((tab) => tab.id), @@ -70,12 +77,16 @@ function persistState(state: SidebarSplitState): void { function renderContainer({ activeTabId = "tab-a", + isFullScreen = false, onActivateTab = vi.fn(), + onToggleFullScreen = vi.fn(), renderPane, tabs = TABS, }: { activeTabId?: string; + isFullScreen?: boolean; onActivateTab?: (tabId: string) => void; + onToggleFullScreen?: () => void; renderPane: (args: SidebarSplitPaneRenderArgs) => ReactNode; tabs?: readonly SidebarSplitTabDescriptor[]; }) { @@ -84,8 +95,10 @@ function renderContainer({ void; + paneId: string; + side: "left" | "right" | "top" | "bottom"; +}) { + const [instance] = useState(() => `${paneId}-${nextPaneInstance++}`); + return ( +
+ {instance} + {canMove ? ( + + ) : null} +
+ ); +} + describe("SidebarSplitContainer", () => { beforeEach(() => { window.localStorage.clear(); @@ -157,11 +196,13 @@ describe("SidebarSplitContainer", () => { return ( { activate(tabId); setActiveTabId(tabId); }} onGlobalTabReorder={vi.fn()} + onToggleFullScreen={vi.fn()} panelStateId={PANEL_STATE_ID} renderPane={({ paneId }) => (
{paneId}
@@ -249,8 +290,10 @@ describe("SidebarSplitContainer", () => { (
@@ -281,13 +324,13 @@ describe("SidebarSplitContainer", () => { }); it.each([ - ["left", "flex-row", "tab-a,tab-b"], - ["right", "flex-row", "tab-b,tab-a"], - ["top", "flex-col", "tab-a,tab-b"], - ["bottom", "flex-col", "tab-b,tab-a"], + ["left", "row", "tab-a,tab-b"], + ["right", "row", "tab-b,tab-a"], + ["top", "col", "tab-a,tab-b"], + ["bottom", "col", "tab-b,tab-a"], ] as const)( "moves the active tab to the supported %s position without dragging", - (side, directionClass, expectedOrder) => { + (side, expectedDirection, expectedOrder) => { renderContainer({ renderPane: ({ group, onMoveActiveTabToSide }) => ( - ) : null} -
- ), - }); + renderContainer({ + tabs, + renderPane: ({ group, onMoveActiveTabToSide, paneId }) => ( + 1} + onMove={() => onMoveActiveTabToSide?.(side)} + paneId={paneId} + side={side} + /> + ), + }); + const originalPaneIds = listPaneIds(); + const instancesBefore = new Map( + originalPaneIds.map((paneId) => [ + paneId, + screen.getByTestId(`multi-instance-${paneId}`).textContent, + ]), + ); - fireEvent.click( - screen.getByRole("button", { name: "Move focused bottom" }), - ); - expect( - screen - .getAllByTestId("active-pane-tab") - .map((tab) => tab.textContent) - .join(","), - ).toBe("tab-b,tab-a"); - }); + fireEvent.click( + screen.getByRole("button", { name: `Move tab-a ${side}` }), + ); + + expect(listPaneIds()).toHaveLength(3); + for (const paneId of originalPaneIds) { + expect(screen.getByTestId(`multi-instance-${paneId}`).textContent).toBe( + instancesBefore.get(paneId), + ); + } + }, + ); + + it.each([ + ["left", "row", "tab-a,tab-b"], + ["right", "row", "tab-b,tab-a"], + ["top", "col", "tab-a,tab-b"], + ["bottom", "col", "tab-b,tab-a"], + ] as const)( + "moves the unfocused Info pane %s from its own control", + (side, expectedDirection, expectedOrder) => { + const split = createTwoPaneState(); + const infoPane = + split.layout.root.type === "split" && + split.layout.root.children[0]?.type === "pane" + ? split.layout.root.children[0] + : null; + const diffPane = + split.layout.root.type === "split" && + split.layout.root.children[1]?.type === "pane" + ? split.layout.root.children[1] + : null; + expect(infoPane).not.toBeNull(); + expect(diffPane).not.toBeNull(); + if (infoPane === null || diffPane === null) return; + persistState(focusSidebarPane(split, diffPane.paneId)); + + renderContainer({ + renderPane: ({ group, onMoveActiveTabToSide, paneId }) => ( +
+ {group.activeTabId} + {paneId === infoPane.paneId ? ( + + ) : null} +
+ ), + }); + + fireEvent.click( + screen.getByRole("button", { name: `Move Info ${side}` }), + ); + expect( + document.querySelector("[data-sidebar-split-container]") + ?.dataset.sidebarSplitRootDirection, + ).toBe(expectedDirection); + expect( + screen + .getAllByTestId("active-pane-tab") + .map((tab) => tab.textContent) + .join(","), + ).toBe(expectedOrder); + }, + ); it("keeps stateful pane content attached to pane identity after a move", () => { const split = createTwoPaneState(); @@ -398,6 +510,149 @@ describe("SidebarSplitContainer", () => { } }); + it("maximizes one pane while preserving the mounted split for restoration", async () => { + const split = createStackedPaneState(); + const paneIds = + split.layout.root.type === "split" + ? split.layout.root.children.flatMap((child) => + child.type === "pane" ? [child.paneId] : [], + ) + : []; + const paneToMaximize = paneIds[0]; + expect(paneToMaximize).toBeDefined(); + if (paneToMaximize === undefined) return; + persistState(split); + + function Pane({ + isMaximized, + onToggleMaximize, + paneId, + }: Pick< + SidebarSplitPaneRenderArgs, + "isMaximized" | "onToggleMaximize" | "paneId" + >) { + const [instance] = useState(() => `${paneId}-${nextPaneInstance++}`); + return ( +
+ {instance} + +
+ ); + } + + function Harness() { + const [isFullScreen, setIsFullScreen] = useState(false); + return ( + setIsFullScreen((current) => !current)} + panelStateId={PANEL_STATE_ID} + renderPane={(pane) => } + tabs={TABS} + /> + ); + } + + render( + + + + + , + ); + const instancesBefore = new Map( + paneIds.map((paneId) => [ + paneId, + screen.getByTestId(`max-instance-${paneId}`).textContent, + ]), + ); + + fireEvent.click( + screen.getByRole("button", { name: `Maximize ${paneToMaximize}` }), + ); + + await waitFor(() => + expect( + document.querySelector( + `[data-split-pane-id="${paneToMaximize}"][data-maximized="true"]`, + ), + ).not.toBeNull(), + ); + const hiddenPaneId = paneIds.find((paneId) => paneId !== paneToMaximize); + expect(hiddenPaneId).toBeDefined(); + if (hiddenPaneId === undefined) return; + const hiddenPane = document.querySelector( + `[data-split-pane-id="${hiddenPaneId}"]`, + ); + if (!(hiddenPane instanceof HTMLElement)) { + throw new Error("Expected the preserved hidden split pane"); + } + expect(hiddenPane.getAttribute("aria-hidden")).toBe("true"); + expect(hiddenPane.style.contentVisibility).toBe("hidden"); + expect(hiddenPane.className).toContain("invisible"); + expect(hiddenPane.className).toContain("pointer-events-none"); + expect(document.querySelector('[role="separator"]')?.className).toContain( + "invisible", + ); + for (const paneId of paneIds) { + expect(screen.getByTestId(`max-instance-${paneId}`).textContent).toBe( + instancesBefore.get(paneId), + ); + } + + fireEvent.click( + screen.getByRole("button", { name: `Restore ${paneToMaximize}` }), + ); + await waitFor(() => + expect( + document.querySelector('[data-split-pane-id][data-maximized="true"]'), + ).toBeNull(), + ); + expect(hiddenPane.getAttribute("aria-hidden")).toBeNull(); + expect(hiddenPane.style.contentVisibility).toBe(""); + expect(screen.getByRole("separator")).not.toBeNull(); + for (const paneId of paneIds) { + expect(screen.getByTestId(`max-instance-${paneId}`).textContent).toBe( + instancesBefore.get(paneId), + ); + } + }); + + it("keeps a newly created split maximized while the right panel is full screen", () => { + const onToggleFullScreen = vi.fn(); + renderContainer({ + isFullScreen: true, + onToggleFullScreen, + renderPane: ({ onMoveActiveTabToSide, paneId }) => ( + + ), + }); + + fireEvent.click(screen.getByRole("button", { name: /^Split / })); + + const panes = Array.from( + document.querySelectorAll("[data-split-pane-id]"), + ); + expect(panes).toHaveLength(2); + expect(panes.filter((pane) => pane.dataset.maximized === "true")).toHaveLength( + 1, + ); + expect( + panes.filter((pane) => pane.getAttribute("aria-hidden") === "true"), + ).toHaveLength(1); + expect(onToggleFullScreen).not.toHaveBeenCalled(); + }); + it.each([ [ "side-by-side", @@ -509,6 +764,17 @@ describe("SidebarSplitContainer", () => { }); expect(Number.parseFloat(previous.style.flex)).toBeCloseTo(0.749, 3); expect(Number.parseFloat(next.style.flex)).toBeCloseTo(0.251, 3); + const panes = Array.from( + document.querySelectorAll("[data-split-pane-id]"), + ); + expect(Number.parseFloat(panes[0]?.style.height ?? "0")).toBeCloseTo( + 74.9, + 1, + ); + expect(Number.parseFloat(panes[1]?.style.height ?? "0")).toBeCloseTo( + 25.1, + 1, + ); fireEvent.pointerUp(hitTarget, { clientX: 700, clientY: 600, @@ -691,9 +957,9 @@ describe("SidebarSplitContainer", () => { }); fireEvent.pointerDown(hitTarget, { clientY: 400, pointerId: 9 }); - expect( - screen.getByTestId("iframe-drag-guard-overlay").className, - ).toContain("cursor-row-resize"); + expect(screen.getByTestId("iframe-drag-guard-overlay").className).toContain( + "cursor-row-resize", + ); fireEvent.pointerCancel(hitTarget, { clientY: 400, pointerId: 9 }); expect(screen.queryByTestId("iframe-drag-guard-overlay")).toBeNull(); diff --git a/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx index 7b0851ac56..c47f569237 100644 --- a/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx +++ b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx @@ -18,6 +18,7 @@ import { listPanes, MAX_PANES, type LayoutNode, + type SplitLayout, type SplitPath, type SplitSide, } from "@/lib/split-layout"; @@ -44,14 +45,17 @@ import { resizeSidebarSplit, selectSidebarTab, serializeSidebarSplitState, + setSidebarPaneMaximized, sidebarPaneGroupId, sidebarSplitStorageKey, + toggleSidebarPaneMaximize, type SidebarSplitState, type SidebarTabGroup, } from "./sidebarSplitLayout"; import type { SecondaryPanelTabReorderRequest } from "./secondaryPanelTab"; const PANE_DRAG_ENGAGE_DISTANCE_PX = 7; +const PANE_EDGE_EPSILON = 1e-9; type SidebarSplitResizeCursor = "col-resize" | "row-resize"; export interface SidebarSplitTabDescriptor { @@ -63,6 +67,7 @@ export interface SidebarSplitPaneRenderArgs { group: SidebarTabGroup; isFocused: boolean; isLeftEdge: boolean; + isMaximized: boolean; isTopRow: boolean; onBeginTabDrag: ( tabId: string, @@ -73,14 +78,17 @@ export interface SidebarSplitPaneRenderArgs { onRemoveSplit?: () => void; onMoveActiveTabToSide?: (side: SplitSide) => void; onSelectTab: (tabId: string) => void; + onToggleMaximize: () => void; paneId: string; showOuterControls: boolean; } interface SidebarSplitContainerProps { activeTabId: string; + isFullScreen: boolean; onActivateTab: (tabId: string) => void; onGlobalTabReorder: (request: SecondaryPanelTabReorderRequest) => void; + onToggleFullScreen: () => void; panelStateId: string; renderPane: (args: SidebarSplitPaneRenderArgs) => ReactNode; tabs: readonly SidebarSplitTabDescriptor[]; @@ -88,8 +96,10 @@ interface SidebarSplitContainerProps { export function SidebarSplitContainer({ activeTabId, + isFullScreen, onActivateTab, onGlobalTabReorder, + onToggleFullScreen, panelStateId, renderPane, tabs, @@ -101,24 +111,32 @@ export function SidebarSplitContainer({ ? null : window.localStorage.getItem(storageKey), ); - const [state, setState] = useState(() => - typeof window === "undefined" - ? createSidebarSplitState(availableTabIds, activeTabId) - : parseSidebarSplitState( - initialStorageValue, - availableTabIds, - activeTabId, - ), - ); + const [state, setState] = useState(() => { + const restored = + typeof window === "undefined" + ? createSidebarSplitState(availableTabIds, activeTabId) + : parseSidebarSplitState( + initialStorageValue, + availableTabIds, + activeTabId, + ); + return setSidebarPaneMaximized( + restored, + isFullScreen ? restored.layout.focusedPaneId : null, + ); + }); const stateRef = useRef(state); const lastPersistedValueRef = useRef({ storageKey, value: initialStorageValue, }); const previousActiveTabId = useRef(activeTabId); + const previousFullScreen = useRef(isFullScreen); const dimsInactiveSplits = useAtomValue(dimInactiveSplitsAtom); const [resizeCursor, setResizeCursor] = useState(null); + const [resizePreviewLayout, setResizePreviewLayout] = + useState(null); const paneCount = countPanes(state.layout.root); const hasMultiplePanes = paneCount > 1; @@ -217,6 +235,17 @@ export function SidebarSplitContainer({ [activeTabId, onActivateTab], ); + useEffect(() => { + if (previousFullScreen.current === isFullScreen) return; + previousFullScreen.current = isFullScreen; + commitState((current) => + setSidebarPaneMaximized( + current, + isFullScreen ? current.layout.focusedPaneId : null, + ), + ); + }, [commitState, isFullScreen]); + const selectTab = useCallback( (paneId: string, tabId: string) => { commitState((current) => selectSidebarTab(current, paneId, tabId)); @@ -240,23 +269,26 @@ export function SidebarSplitContainer({ ); const moveActiveTabToSide = useCallback( - (side: SplitSide) => { + (paneId: string, side: SplitSide) => { commitState((current) => { - const paneId = current.layout.focusedPaneId; - const sourceGroup = getSidebarGroupForPane(current, paneId); + const focused = focusSidebarPane(current, paneId); + const sourceGroup = getSidebarGroupForPane(focused, paneId); if (sourceGroup === null) return current; if (sourceGroup.tabIds.length > 1) { - if (countPanes(current.layout.root) >= MAX_PANES) return current; - return moveSidebarTab( - current, + if (countPanes(focused.layout.root) >= MAX_PANES) return focused; + const moved = moveSidebarTab( + focused, paneId, sourceGroup.activeTabId, { paneId, zone: side }, - { groupId: nextSidebarSplitGroupId(current) }, + { groupId: nextSidebarSplitGroupId(focused) }, ); + return isFullScreen + ? setSidebarPaneMaximized(moved, moved.layout.focusedPaneId) + : moved; } - const rects = computePaneRects(current.layout.root); - const target = listPanes(current.layout.root) + const rects = computePaneRects(focused.layout.root); + const target = listPanes(focused.layout.root) .filter((pane) => pane.paneId !== paneId) .sort((first, second) => { const a = rects.get(first.paneId); @@ -277,25 +309,45 @@ export function SidebarSplitContainer({ return edge(a) - edge(b); })[0]; return target === undefined - ? current - : moveSidebarPaneToSide(current, paneId, target.paneId, side); + ? focused + : moveSidebarPaneToSide(focused, paneId, target.paneId, side); }, true); }, - [commitState], + [commitState, isFullScreen], + ); + + const toggleMaximizePane = useCallback( + (paneId: string) => { + const current = stateRef.current; + const next = commitState((latest) => + toggleSidebarPaneMaximize(latest, paneId), + ); + if ( + next !== current && + (next.maximizedPaneId !== null) !== isFullScreen + ) { + onToggleFullScreen(); + } + }, + [commitState, isFullScreen, onToggleFullScreen], ); const moveTab = useCallback( (sourcePaneId: string, tabId: string, target: SplitDropTarget) => { const groupId = nextSidebarSplitGroupId(stateRef.current); commitState( - (current) => - moveSidebarTab(current, sourcePaneId, tabId, target, { + (current) => { + const moved = moveSidebarTab(current, sourcePaneId, tabId, target, { groupId, - }), + }); + return isFullScreen + ? setSidebarPaneMaximized(moved, moved.layout.focusedPaneId) + : moved; + }, true, ); }, - [commitState], + [commitState, isFullScreen], ); const beginTabDrag = useCallback( @@ -387,18 +439,28 @@ export function SidebarSplitContainer({ }, [commitState], ); + const previewResize = useCallback( + (path: SplitPath, childIndex: number, fraction: number | null) => { + setResizePreviewLayout( + fraction === null + ? null + : resizeSidebarSplit(stateRef.current, path, childIndex, fraction) + .layout, + ); + }, + [], + ); const firstPane = listPanes(state.layout.root)[0]; - const focusedGroup = getSidebarGroupForPane( - state, - state.layout.focusedPaneId, - ); - const canMoveActiveTabToSide = - focusedGroup !== null && - (focusedGroup.tabIds.length > 1 ? paneCount < MAX_PANES : paneCount > 1); - const activeTabPositionHandler = canMoveActiveTabToSide - ? moveActiveTabToSide - : undefined; + const moveActiveTabHandler = (paneId: string) => { + const group = getSidebarGroupForPane(state, paneId); + const canMove = + group !== null && + (group.tabIds.length > 1 ? paneCount < MAX_PANES : paneCount > 1); + return canMove + ? (side: SplitSide) => moveActiveTabToSide(paneId, side) + : undefined; + }; if (!hasMultiplePanes && firstPane !== undefined) { const group = getSidebarGroupForPane(state, firstPane.paneId); if (group === null) return null; @@ -408,43 +470,66 @@ export function SidebarSplitContainer({ group, isFocused: true, isLeftEdge: true, + isMaximized: isFullScreen, isTopRow: true, onBeginTabDrag: (tabId, event) => beginTabDrag(firstPane.paneId, tabId, event), onReorderTab: (request) => reorderTab(firstPane.paneId, request), onFocusPane: () => focusPane(firstPane.paneId), onRemoveSplit: undefined, - onMoveActiveTabToSide: activeTabPositionHandler, + onMoveActiveTabToSide: moveActiveTabHandler(firstPane.paneId), onSelectTab: (tabId) => selectTab(firstPane.paneId, tabId), + onToggleMaximize: onToggleFullScreen, paneId: firstPane.paneId, showOuterControls: true, }); } + const presentedLayout = resizePreviewLayout ?? state.layout; + const paneRects = computePaneRects(presentedLayout.root); return (
- + {listPanes(state.layout.root).map((pane) => { + const rect = paneRects.get(pane.paneId); + return rect === undefined ? null : ( + = 1 - PANE_EDGE_EPSILON} + isTopRow={rect.y <= PANE_EDGE_EPSILON} + maximizedPaneId={state.maximizedPaneId} + pane={pane} + rect={rect} + renderPane={renderPane} + state={state} + onBeginTabDrag={beginTabDrag} + onFocusPane={focusPane} + onRemoveSplit={removeSplit} + onMoveActiveTabToSide={moveActiveTabToSide} + onReorderTab={reorderTab} + onSelectTab={selectTab} + onToggleMaximize={toggleMaximizePane} + /> + ); + })} , - ) => void; - onFocusPane: (paneId: string) => void; - onRemoveSplit: (paneId: string) => void; - onMoveActiveTabToSide?: (side: SplitSide) => void; - onReorderTab: ( - paneId: string, - request: SecondaryPanelTabReorderRequest, - ) => void; onResize: (path: SplitPath, childIndex: number, fraction: number) => void; onResizeDragChange: (cursor: SidebarSplitResizeCursor | null) => void; - onSelectTab: (paneId: string, tabId: string) => void; + onPreviewResize: ( + path: SplitPath, + childIndex: number, + fraction: number | null, + ) => void; path: number[]; - renderPane: (args: SidebarSplitPaneRenderArgs) => ReactNode; - state: SidebarSplitState; } -function SidebarSplitTree(props: SidebarSplitTreeProps) { +function SidebarSplitTrackTree(props: SidebarSplitTrackTreeProps) { if (props.node.type === "pane") { - return ; + return
; } const node = props.node; return (
@@ -497,28 +568,24 @@ function SidebarSplitTree(props: SidebarSplitTreeProps) { {index > 0 ? (