Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
type ComponentProps,
type ReactNode,
} from "react";
import { StyleSheet, View } from "react-native";
import { StyleSheet, useWindowDimensions, View } from "react-native";
import { Split, type SplitHostCommands } from "react-native-screens";
import { SafeAreaView } from "react-native-safe-area-context";
import { GestureHandlerRootView } from "react-native-gesture-handler";
Expand All @@ -40,6 +40,10 @@ import {
WorkspaceNativeTabs,
WorkspaceSidebarTabsFooter,
} from "./workspace/WorkspaceNativeTabs.ios";
import {
useCompactWorkspaceTabs,
useFloatingWorkspaceTabs,
} from "./workspace/useSystemWorkspaceTabBar";
import { workspaceUsesColumns } from "./workspace/workspace-tabs-state";
import { isSettingsRoute, settingsOpenIn } from "../settings/settings-navigation";
import { workspaceRoutesOf } from "./workspace-overlay-routes";
Expand Down Expand Up @@ -496,19 +500,46 @@ function WorkspaceColumns(
);
}

function CardStackDock() {
const deviceType = useDeviceType();
const { layout } = useAdaptiveWorkspaceLayout();
const floatingTabs = useFloatingWorkspaceTabs();
const { width } = useWindowDimensions();
return (
<View
pointerEvents="box-none"
style={{
position: "absolute",
left: 0,
bottom: 0,
// UIKit can shrink the sidebar to 280pt independently of its preferred width.
width:
deviceType === "pad" && (floatingTabs || layout.usesSplitView)
? Math.min(280, layout.listPaneWidth ?? width, width)
: (layout.listPaneWidth ?? width),
height: 128,
}}
>
<WorkspaceSidebarTabsFooter />
</View>
);
}

function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean }) {
const { layout } = useAdaptiveWorkspaceLayout();
const { panes } = useWorkspacePaneState();
const deviceType = useDeviceType();
const workspace = useWorkspaceTabs()!;
// Native dismiss commits the route pop after the transition. Restore the dock
const compactTabs = useCompactWorkspaceTabs();
// Native dismiss commits the route pop after the transition. Restore the system tab bar
// when the primary screen starts appearing, including interactive back gestures.
const [primaryVisible, setPrimaryVisible] = useState(false);
const metrics = useNativeLayoutMetrics();
const projection = projectWorkspaceStack(props.state, (route) => {
return isPresentedRoute(props.descriptors[route.key]?.options);
});
// The root's willDisappear reaches JS frames into the push, so hide the dock in the commit that
// pushes; the native events still own every later change, including back gestures.
// The root's willDisappear reaches JS frames into the push, so hide the system tab bar in the
// commit that pushes; the native events still own every later change, including back gestures.
const topDetailKey = projection.detail.at(-1)?.key;
const [pushedDetailKey, setPushedDetailKey] = useState(topDetailKey);
if (pushedDetailKey !== topDetailKey) {
Expand All @@ -523,6 +554,21 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean
const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" };
const visibleRoute = projection.detail.at(-1) ?? primaryRoute;
const workspacePage = !workspaceUsesColumns(workspace.state.selectedTab, workspace.settingsOpen);
const tabsInSidebarColumn = props.nativeColumns && !workspacePage && deviceType === "pad";
// Keep the floating dock on the root card so a back swipe reveals it under the leaving screen
// instead of mounting an overlay mid-gesture or after the transition settles. Column layouts put
// the same footer on the sidebar screen instead. Medium Usage is a page (no columns), so it still
// needs this dock; medium Chats/PRs use the sidebar toolbar.
const cardStackDock =
!tabsInSidebarColumn &&
(deviceType === "pad" || compactTabs || (layout.variant === "medium" && workspacePage));
// Pre-iPadOS 26 JS split keeps the sidebar beside a covered root, so the dock must stay above
// the detail. Non-split stacks use the root-card footer for the interactive back swipe.
const dockUnderLeavingCard = cardStackDock && !layout.usesSplitView;
const dockAboveStack =
cardStackDock &&
layout.usesSplitView &&
!(deviceType === "pad" && !workspacePage && !panes.primarySidebarVisible);
const baseDescriptor: Descriptor = {
...primaryDescriptor,
navigation: props.descriptors[visibleRoute.key]!.navigation,
Expand All @@ -531,7 +577,6 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean
options: { headerShown: false },
render: () => (
<WorkspaceNativeTabs
tabsInSidebarColumn={props.nativeColumns && !workspacePage && deviceType === "pad"}
hidden={
!workspacePage &&
projection.detail.length > 0 &&
Expand All @@ -548,18 +593,22 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean
detail={projection.detail}
/>
) : (
<V5CardStackView
{...props}
onRootVisibilityChange={setPrimaryVisible}
onNativePopChange={setNativelyPoppingWorkspaceRoute}
descriptors={descriptors}
state={{
...props.state,
routes: [primaryRoute, ...projection.detail],
index: projection.detail.length,
preloadedRoutes: [],
}}
/>
<View style={{ flex: 1 }}>
<V5CardStackView
{...props}
rootFooter={dockUnderLeavingCard ? <CardStackDock /> : undefined}
onRootVisibilityChange={setPrimaryVisible}
onNativePopChange={setNativelyPoppingWorkspaceRoute}
descriptors={descriptors}
state={{
...props.state,
routes: [primaryRoute, ...projection.detail],
index: projection.detail.length,
preloadedRoutes: [],
}}
/>
{dockAboveStack ? <CardStackDock /> : null}
</View>
)}
</WorkspaceNativeTabs>
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,23 @@ import {
useEnabledNavigationTabs,
} from "./navigation-tabs";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { View, useWindowDimensions, type NativeSyntheticEvent } from "react-native";
import { View, type NativeSyntheticEvent } from "react-native";
import { Tabs, type TabSelectedEvent, type TabSelectionRejectedEvent } from "react-native-screens";

import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";
import {
NATIVE_WORKSPACE_TAB_SLOTS,
workspaceTabForNativeSlot,
workspaceUsesColumns,
type WorkspaceTab,
} from "./workspace-tabs-state";
import {
NativeWorkspaceTabActivityContext,
WorkspaceSlotOptionsContext,
} from "../../../native/v5-workspace-context";
import {
useCompactWorkspaceTabs,
useFloatingWorkspaceTabs,
useSystemWorkspaceTabBar,
} from "./useSystemWorkspaceTabBar";
import { useFloatingWorkspaceTabs, useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar";
import { useControlTheme } from "../../../components/ui/useControlTheme";
import { useDeviceType } from "../../../native/native-layout-metrics";
import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs";
import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "../AdaptiveWorkspaceLayout";
import { useAdaptiveWorkspaceLayout } from "../AdaptiveWorkspaceLayout";
import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab";

const PULL_REQUEST_ICON = require("../../../../assets/icons/pull-request.png");
Expand Down Expand Up @@ -73,8 +67,6 @@ function RetainedWorkspaceTab(props: {
export function WorkspaceNativeTabs(props: {
readonly children: ReactNode;
readonly hidden: boolean;
/** The sidebar column draws the tabs itself, so they leave with it. */
readonly tabsInSidebarColumn?: boolean;
}) {
const workspace = useWorkspaceTabs()!;
const order = useEnabledNavigationTabs();
Expand All @@ -83,18 +75,6 @@ export function WorkspaceNativeTabs(props: {
const selectTab = useSelectWorkspaceTab();
const [provenance, setProvenance] = useState(0);
const systemTabBar = useSystemWorkspaceTabBar();
const deviceType = useDeviceType();
const compactTabs = useCompactWorkspaceTabs();
const { layout } = useAdaptiveWorkspaceLayout();
const { panes } = useWorkspacePaneState();
// The tabs belong to the sidebar; over a page with the sidebar hidden they'd cover the composer.
const hiddenWithSidebar =
deviceType === "pad" &&
layout.usesSplitView &&
workspaceUsesColumns(workspace.state.selectedTab, workspace.settingsOpen) &&
!panes.primarySidebarVisible;
const floatingTabs = useFloatingWorkspaceTabs();
const { width } = useWindowDimensions();
const { colors } = useControlTheme();
const selectedTab = workspace.state.selectedTab;
const selectedSlot = workspace.settingsOpen ? -1 : primaryTabs.indexOf(selectedTab);
Expand Down Expand Up @@ -158,30 +138,6 @@ export function WorkspaceNativeTabs(props: {
);
})}
</Tabs.Host>
{(deviceType === "pad" ||
compactTabs ||
(layout.variant === "medium" &&
!workspaceUsesColumns(selectedTab, workspace.settingsOpen))) &&
!props.hidden &&
!props.tabsInSidebarColumn &&
!hiddenWithSidebar ? (
<View
pointerEvents="box-none"
style={{
position: "absolute",
left: 0,
bottom: 0,
// UIKit can shrink the sidebar to 280pt independently of its preferred width.
width:
deviceType === "pad" && (floatingTabs || layout.usesSplitView)
? Math.min(280, layout.listPaneWidth ?? width, width)
: (layout.listPaneWidth ?? width),
height: 128,
}}
>
<WorkspaceSidebarTabsFooter />
</View>
) : null}
</View>
Comment thread
wSedlacek marked this conversation as resolved.
Resolved
);
}
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/native/createV5StackNavigator.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
type NativeStackNavigatorProps,
type NativeStackTypeBag,
} from "@react-navigation/native-stack";
import { use, useCallback, useRef, useState, type ComponentProps } from "react";
import { use, useCallback, useRef, useState, type ComponentProps, type ReactNode } from "react";
import { WorkspaceSlotOptionsContext, slotScreenOptions } from "./v5-workspace-context";
import { StyleSheet, View } from "react-native";
import { Stack } from "react-native-screens";
Expand Down Expand Up @@ -57,6 +57,8 @@ export function V5CardStackView(
readonly onRootVisibilityChange?: (visible: boolean) => void;
/** The card native Back is popping, which the router still shows until the pop finishes. */
readonly onNativePopChange?: (key: string | null) => void;
/** Drawn on the root card so an interactive pop reveals it under the leaving screen. */
readonly rootFooter?: ReactNode;
},
) {
const { preventedRoutes } = usePreventRemoveContext();
Expand Down Expand Up @@ -253,6 +255,7 @@ export function V5CardStackView(
{descriptor.render()}
</NativeScrollEdgeEffectsContext>
</NativeColumnContent>
{index === 0 ? props.rootFooter : null}
</NavigationRouteContext>
</NavigationContext>
</Stack.Screen>
Expand Down
Loading