Skip to content
Source
Sign inSign up
Open History to see the last commit

WorkspaceNativeTabs.ios.tsx

205 lines8.0 KB

1import {
2 WORKSPACE_TAB_LABELS,
3 NAVIGATION_TAB_ICONS,
4 useEnabledNavigationTabs,
5} from "./navigation-tabs";
6import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
7import { View, useWindowDimensions, type NativeSyntheticEvent } from "react-native";
8import { Tabs, type TabSelectedEvent, type TabSelectionRejectedEvent } from "react-native-screens";
9​
10import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";
11import {
12 NATIVE_WORKSPACE_TAB_SLOTS,
13 workspaceTabForNativeSlot,
14 workspaceUsesColumns,
15 type WorkspaceTab,
16} from "./workspace-tabs-state";
17import {
18 NativeWorkspaceTabActivityContext,
19 WorkspaceSlotOptionsContext,
20} from "../../../native/v5-workspace-context";
21import {
22 useCompactWorkspaceTabs,
23 useFloatingWorkspaceTabs,
24 useSystemWorkspaceTabBar,
25} from "./useSystemWorkspaceTabBar";
26import { useControlTheme } from "../../../components/ui/useControlTheme";
27import { useDeviceType } from "../../../native/native-layout-metrics";
28import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs";
29import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "../AdaptiveWorkspaceLayout";
30import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab";
31​
32const PULL_REQUEST_ICON = require("../../../../assets/icons/pull-request.png");
33​
34/** Retain each native controller tree; removing its root can leave RNS's stack empty. */
35function RetainedWorkspaceTab(props: {
36 readonly tab: WorkspaceTab;
37 readonly active: boolean;
38 readonly children: ReactNode;
39}) {
40 const workspace = useWorkspaceTabs()!;
41 // Whether this slot is the visible one, read by cleanups after the switch has committed.
42 const activeRef = useRef(props.active);
43 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed active flag to a ref that header cleanups read after the tab switch commits
44 useLayoutEffect(() => {
45 activeRef.current = props.active;
46 }, [props.active]);
47 const isActive = () => activeRef.current;
48 const activity = {
49 active: props.active,
50 isActive,
51 };
52 const [optionsByRoute, setOptionsByRoute] = useState<ReadonlyMap<string, object>>(new Map());
53 // Stable, so writers and the cleanups that depend on them don't re-run on every write.
54 const setRouteOptions = (routeKey: string, options: object) =>
55 setOptionsByRoute((current) =>
56 new Map(current).set(routeKey, { ...current.get(routeKey), ...options }),
57 );
58 const slotOptions = { byRoute: optionsByRoute, set: setRouteOptions };
59 const [content, setContent] = useState(
60 props.active ? { workspace, children: props.children } : null,
61 );
62 if (props.active && (content?.workspace !== workspace || content?.children !== props.children))
63 setContent({ workspace, children: props.children });
64 return content ? (
65 <NativeWorkspaceTabActivityContext value={activity}>
66 <WorkspaceSlotOptionsContext value={slotOptions}>
67 <WorkspaceTabsContext value={content.workspace}>{content.children}</WorkspaceTabsContext>
68 </WorkspaceSlotOptionsContext>
69 </NativeWorkspaceTabActivityContext>
70 ) : null;
72​
73export function WorkspaceNativeTabs(props: {
74 readonly children: ReactNode;
75 readonly hidden: boolean;
76 /** The sidebar column draws the tabs itself, so they leave with it. */
77 readonly tabsInSidebarColumn?: boolean;
78}) {
79 const workspace = useWorkspaceTabs()!;
80 const order = useEnabledNavigationTabs();
81 const primaryTabs = order.slice(0, 4);
82 const overflow = order[4];
83 const selectTab = useSelectWorkspaceTab();
84 const [provenance, setProvenance] = useState(0);
85 const systemTabBar = useSystemWorkspaceTabBar();
86 const deviceType = useDeviceType();
87 const compactTabs = useCompactWorkspaceTabs();
88 const { layout } = useAdaptiveWorkspaceLayout();
89 const { panes } = useWorkspacePaneState();
90 // The tabs belong to the sidebar; over a page with the sidebar hidden they'd cover the composer.
91 const hiddenWithSidebar =
92 deviceType === "pad" &&
93 layout.usesSplitView &&
94 workspaceUsesColumns(workspace.state.selectedTab, workspace.settingsOpen) &&
95 !panes.primarySidebarVisible;
96 const floatingTabs = useFloatingWorkspaceTabs();
97 const { width } = useWindowDimensions();
98 const { colors } = useControlTheme();
99 const selectedTab = workspace.state.selectedTab;
100 const selectedSlot = workspace.settingsOpen ? -1 : primaryTabs.indexOf(selectedTab);
101 const request = {
102 selectedScreenKey: selectedSlot === -1 ? "settings" : NATIVE_WORKSPACE_TAB_SLOTS[selectedSlot]!,
103 baseProvenance: provenance,
104 };
105 return (
106 <View style={{ flex: 1 }}>
107 <Tabs.Host
108 navStateRequest={request}
109 tabBarHidden={props.hidden || !systemTabBar}
110 ios={{
111 tabBarControllerMode: "tabBar",
112 tabBarMinimizeBehavior: "never",
113 tabBarTintColor: colors["--color-primary"],
114 }}
115 onTabSelectionRejected={({
116 nativeEvent,
117 }: NativeSyntheticEvent<TabSelectionRejectedEvent>) =>
118 setProvenance(nativeEvent.provenance)
119 }
120 onTabSelected={({ nativeEvent }: NativeSyntheticEvent<TabSelectedEvent>) => {
121 setProvenance(nativeEvent.provenance);
122 if (nativeEvent.actionOrigin === "programmatic-js") return;
123 const tab = workspaceTabForNativeSlot(primaryTabs, nativeEvent.selectedScreenKey);
124 if (tab) selectTab(tab);
125 }}
126 >
127 {NATIVE_WORKSPACE_TAB_SLOTS.map((slot, index) => {
128 const settings = slot === "settings";
129 const tab = settings ? (overflow ?? selectedTab) : primaryTabs[index];
130 if (!tab) return null;
131 return (
132 <Tabs.Screen
133 // Stable slot controllers keep UIKit's stacks attached when a section moves into Settings.
134 key={slot}
135 screenKey={slot}
136 title=""
137 tabBarItemAccessibilityLabel={settings ? "Settings" : WORKSPACE_TAB_LABELS[tab]}
138 ios={{
139 icon: settings
140 ? { type: "sfSymbol", name: "gearshape" }
141 : tab === "pullRequests"
142 ? { type: "templateSource", templateSource: PULL_REQUEST_ICON }
143 : { type: "sfSymbol", name: NAVIGATION_TAB_ICONS[tab] },
144 selectedIcon:
145 !settings && tab === "threads"
146 ? { type: "sfSymbol", name: "text.bubble.fill" }
147 : undefined,
148 overrideScrollViewContentInsetAdjustmentBehavior: false,
149 }}
150 >
151 <RetainedWorkspaceTab
152 tab={tab}
153 active={settings ? selectedSlot === -1 : selectedSlot === index}
154 >
155 {props.children}
156 </RetainedWorkspaceTab>
157 </Tabs.Screen>
158 );
159 })}
160 </Tabs.Host>
161 {(deviceType === "pad" ||
162 compactTabs ||
163 (layout.variant === "medium" &&
164 !workspaceUsesColumns(selectedTab, workspace.settingsOpen))) &&
165 !props.hidden &&
166 !props.tabsInSidebarColumn &&
167 !hiddenWithSidebar ? (
168 <View
169 pointerEvents="box-none"
170 style={{
171 position: "absolute",
172 left: 0,
173 bottom: 0,
174 // UIKit can shrink the sidebar to 280pt independently of its preferred width.
175 width:
176 deviceType === "pad" && (floatingTabs || layout.usesSplitView)
177 ? Math.min(280, layout.listPaneWidth ?? width, width)
178 : (layout.listPaneWidth ?? width),
179 height: 128,
180 }}
181 >
182 <WorkspaceSidebarTabsFooter />
183 </View>
184 ) : null}
185 </View>
186 );
188​
189/** The workspace tabs at the foot of the sidebar. */
190export function WorkspaceSidebarTabsFooter() {
191 const workspace = useWorkspaceTabs()!;
192 const { startNewTask } = useAdaptiveWorkspaceLayout();
193 const compact = useFloatingWorkspaceTabs();
194 return (
195 <WorkspaceSidebarTabs
196 compact={compact}
197 reserveComposeSpace={compact}
198 onNewTask={
199 compact && !workspace.settingsOpen && workspace.state.selectedTab === "threads"
200 ? startNewTask
201 : undefined
202 }
203 />
204 );