Skip to content
Source
Sign inSign up

createWorkspaceStackNavigator.ios.tsx

687 lines26.5 KB

1import {
2 createNavigatorFactory,
3 NavigationContext,
4 NavigationRouteContext,
5 StackRouter,
6 useNavigationBuilder,
7 type NavigatorTypeBagBase,
8 type ParamListBase,
9 type StackActionHelpers,
10 type StackNavigationState,
11 type StackRouterOptions,
12 type StaticConfig,
13 type TypedNavigator,
14} from "@react-navigation/native";
15import {
16 NativeStackView,
17 type NativeStackNavigationEventMap,
18 type NativeStackNavigationOptions,
19 type NativeStackNavigatorProps,
20 type NativeStackTypeBag,
21} from "@react-navigation/native-stack";
22import {
23 Fragment,
24 use,
25 useEffect,
26 useRef,
27 useState,
28 type ComponentProps,
29 type ReactNode,
30} from "react";
31import { StyleSheet, View } from "react-native";
32import { Split, type SplitHostCommands } from "react-native-screens";
33import { SafeAreaView } from "react-native-safe-area-context";
34import { GestureHandlerRootView } from "react-native-gesture-handler";
35import { EnvironmentId, ThreadId } from "@t3tools/contracts";
36import { NATIVE_INSPECTOR_MIN_MAIN_WIDTH } from "../../lib/layout";
37import { scopedThreadKey } from "../../lib/scopedEntities";
38import { WorkspaceTabsProvider, useWorkspaceTabs } from "./workspace/WorkspaceTabsContext";
39import {
40 WorkspaceNativeTabs,
41 WorkspaceSidebarTabsFooter,
42} from "./workspace/WorkspaceNativeTabs.ios";
43import { workspaceUsesColumns } from "./workspace/workspace-tabs-state";
44import { isSettingsRoute, settingsOpenIn } from "../settings/settings-navigation";
45import { workspaceRoutesOf } from "./workspace-overlay-routes";
46import { setNativelyPoppingWorkspaceRoute } from "./workspace/workspace-detail-selection";
47import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics";
48​
49import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "./AdaptiveWorkspaceLayout";
50import { WorkspacePaneDivider } from "./workspace-pane-divider";
51import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail";
52import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios";
53import {
54 modalEnvelopeOptions,
55 V5CardStackView,
56 V5StackView,
57} from "../../native/createV5StackNavigator.ios";
58import {
59 useNativeWorkspaceColumnsReady,
60 useNativeWorkspaceColumnsSupported,
61} from "../../native/NativeWorkspaceColumns";
62import { V5StackHeader } from "../../native/V5StackHeader.ios";
63import { InspectorColumnScreens } from "./inspector-host";
64import { PaneContext } from "../../components/ui/Pane";
65import { useWorkspaceSidebarHeaderItem } from "./useWorkspaceSidebarHeaderItem";
66import type { AppNativeStackNavigationOptions } from "../../native/StackHeader";
67import {
68 collapsedSplitTransition,
69 nativeWorkspacePopAction,
70 projectWorkspaceStack,
71 partitionStackPresentations,
72 reconcileStackScreens,
73 type CollapsedSplitColumn,
74 isPresentedRoute,
75} from "../../native/workspace-stack-projection";
76import {
77 NativePrimaryColumnContext,
78 NativeWorkspaceInspectorContext,
79 NativeWorkspaceModeContext,
80 WorkspaceSlotOptionsContext,
81 slotScreenOptions,
82} from "../../native/v5-workspace-context";
83​
84type ViewProps = ComponentProps<typeof NativeStackView>;
85type Descriptor = ViewProps["descriptors"][string];
86type Route = ViewProps["state"]["routes"][number];
87​
88const isPullRequestRoute = (route: Route) =>
89 route.name === "PullRequest" ||
90 route.name === "PullRequestFile" ||
91 route.name === "PullRequestFiles";
92​
93// Every step of starting a task, from the project picker to the draft thread itself.
94const isNewTaskRoute = (route: Route) =>
95 route.name.startsWith("NewTask") ||
96 route.name.startsWith("AddProject") ||
97 (route.params !== undefined && "draft" in route.params && Boolean(route.params.draft));
98​
99/** Beside the list, the first Settings page shows on one native screen that swaps its content. */
100const SETTINGS_DETAIL_SCREEN = "settings-detail";
101​
102function ColumnScreen(props: {
103 readonly descriptor: Descriptor;
104 readonly screenKey?: string;
105 readonly primary?: boolean;
106 readonly attached?: boolean;
107 readonly selectedThreadKey?: string | null;
108 readonly selectedPullRequestKey?: string | null;
109 readonly onDismiss: (routeKey: string, native: boolean) => void;
110 readonly onAppearance: (type: "willAppear" | "didAppear" | "willDisappear", key: string) => void;
111 readonly onCompose?: () => void;
112 readonly navigation: ViewProps["navigation"];
113 readonly footer?: ReactNode;
114}) {
115 const { descriptor } = props;
116 const { layout } = useAdaptiveWorkspaceLayout();
117 // A collapsed split shows the primary column as the page itself, not as a sidebar.
118 const sidebar = props.primary === true && layout.usesSplitView;
119 // Options a screen set inside this workspace tab slot, over its route's.
120 const options = slotScreenOptions(
121 use(WorkspaceSlotOptionsContext),
122 descriptor.route.key,
123 descriptor.options,
124 );
125 const [presented, setPresented] = useState(false);
126 const primaryColumn = props.primary
127 ? {
128 presented,
129 selectedThreadKey: props.selectedThreadKey ?? null,
130 selectedPullRequestKey: props.selectedPullRequestKey ?? null,
131 }
132 : null;
133 const key = descriptor.route.key;
134 return (
135 <Split.Screen
136 screenKey={props.screenKey ?? key}
137 activityMode={props.attached === false ? "detached" : "attached"}
138 onDismiss={(event) => {
139 props.onDismiss(key, event.nativeEvent.isNativeDismiss);
140 }}
141 onWillAppear={() => {
142 props.onAppearance("willAppear", key);
143 props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } });
144 }}
145 onDidAppear={() => {
146 setPresented(true);
147 props.onAppearance("didAppear", key);
148 props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } });
149 }}
150 onWillDisappear={() => {
151 setPresented(false);
152 props.onAppearance("willDisappear", key);
153 props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } });
154 }}
155 onDidDisappear={() =>
156 props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } })
157 }
158 >
159 <PaneContext value={sidebar ? "sidebar" : "content"}>
160 <NavigationContext value={descriptor.navigation}>
161 <NavigationRouteContext value={descriptor.route}>
162 <V5StackHeader
163 options={options}
164 canGoBack={!props.primary}
165 column={props.primary ? "primary" : "detail"}
166 onCompose={isNewTaskRoute(descriptor.route) ? undefined : props.onCompose}
167 />
168 <ColumnContent
169 primary={sidebar}
170 insetHorizontally={
171 (options as AppNativeStackNavigationOptions).nativeContentInsetHorizontally
172 }
173 >
174 <NativePrimaryColumnContext value={primaryColumn}>
175 {/* The Settings slot swaps pages here; its column keeps its measured insets. */}
176 <Fragment key={key}>{descriptor.render()}</Fragment>
177 </NativePrimaryColumnContext>
178 </ColumnContent>
179 {props.footer}
180 </NavigationRouteContext>
181 </NavigationContext>
182 </PaneContext>
183 </Split.Screen>
184 );
186​
187function EmptyDetailHeader(props: { readonly onCompose: (() => void) | undefined }) {
188 const sidebarItem = useWorkspaceSidebarHeaderItem();
189 return (
190 <V5StackHeader
191 options={{
192 headerShown: true,
193 title: "",
194 unstable_headerLeftItems: () => (sidebarItem ? [sidebarItem] : []),
195 }}
196 canGoBack={false}
197 column="detail"
198 onCompose={props.onCompose}
199 />
200 );
202​
203function WorkspaceColumns(
204 props: ViewProps & { readonly primary: Route; readonly detail: Route[] },
205) {
206 const {
207 layout,
208 setPrimarySidebarVisible,
209 toggleAuxiliaryPane,
210 startNewTask,
211 openWorkspaceTab,
212 nativeInspectorWidth,
213 setNativeInspectorWidth,
214 } = useAdaptiveWorkspaceLayout();
215 const { panes, sidebarByOrientation } = useWorkspacePaneState();
216 const deviceType = useDeviceType();
217 const inspectorWidth = nativeInspectorWidth;
218 const resizeStartWidth = useRef(inspectorWidth);
219 const settingsDetailIn = (detail: readonly Route[]) =>
220 layout.usesSplitView && detail[0] && isSettingsRoute(detail[0].name) ? detail[0] : undefined;
221 // Split screens must remain mounted while UIKit animates a detached route away.
222 const [screens, setScreens] = useState({
223 routes: props.detail,
224 descriptors: props.descriptors,
225 observedRoutes: props.detail,
226 observedDescriptors: props.descriptors,
227 completed: new Set<string>(),
228 });
229 if (
230 screens.observedRoutes !== props.detail ||
231 screens.observedDescriptors !== props.descriptors
232 ) {
233 const active = new Set(props.detail.map((route) => route.key));
234 const settingsSlot = settingsDetailIn(props.detail);
235 const routes = reconcileStackScreens(screens.routes, props.detail, screens.completed).filter(
236 // The slot's native screen stays; the pages it showed, and any above them, just go.
237 (route) => !settingsSlot || active.has(route.key) || !isSettingsRoute(route.name),
238 );
239 const retained = new Set(routes.map((route) => route.key));
240 setScreens({
241 routes,
242 descriptors: Object.fromEntries(
243 Object.entries({ ...screens.descriptors, ...props.descriptors }).filter(([key]) =>
244 retained.has(key),
245 ),
246 ),
247 observedRoutes: props.detail,
248 observedDescriptors: props.descriptors,
249 completed: new Set([...screens.completed].filter((key) => active.has(key))),
250 });
251 }
252​
253 const workspaceTabs = useWorkspaceTabs();
254 const selectedTab = workspaceTabs?.state.selectedTab;
255 const settingsOpen = workspaceTabs?.settingsOpen ?? false;
256 const openNewTask = startNewTask;
257 // The cover detail rail needs room for thread tools above the fixed tabs.
258 // The unfolded Duo keeps compose in its detail bar.
259 const detailCompose =
260 deviceType === "duo-unfolded" && selectedTab !== "pullRequests" && !settingsOpen
261 ? openNewTask
262 : undefined;
263 const inspector = use(NativeWorkspaceInspectorContext);
264 const hideInspector = () => {
265 if (inspector.visible) toggleAuxiliaryPane();
266 };
267 const hostRef = useRef<SplitHostCommands>(null);
268 const activeDetailKey = props.detail.at(-1)?.key;
269 const compactColumn = activeDetailKey ? "secondary" : "primary";
270 const collapsed = useRef(!layout.usesSplitView);
271 const collapsedColumn = useRef<CollapsedSplitColumn>({ visible: compactColumn, leaving: null });
272 const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params;
273 const pullRequestParams = props.detail.findLast(isPullRequestRoute)?.params;
274 const selectedPullRequestKey =
275 pullRequestParams &&
276 "environmentId" in pullRequestParams &&
277 "host" in pullRequestParams &&
278 "repository" in pullRequestParams &&
279 "number" in pullRequestParams &&
280 typeof pullRequestParams.environmentId === "string" &&
281 typeof pullRequestParams.host === "string" &&
282 typeof pullRequestParams.repository === "string" &&
283 typeof pullRequestParams.number === "number"
284 ? `${pullRequestParams.environmentId}:${pullRequestParams.host}:${pullRequestParams.repository}:${pullRequestParams.number}`
285 : null;
286 const selectedThreadKey =
287 threadParams &&
288 "environmentId" in threadParams &&
289 "threadId" in threadParams &&
290 typeof threadParams.environmentId === "string" &&
291 typeof threadParams.threadId === "string"
292 ? scopedThreadKey(
293 EnvironmentId.make(threadParams.environmentId),
294 ThreadId.make(threadParams.threadId),
295 )
296 : null;
297 const handleNativeDismiss = (key: string, native: boolean) => {
298 setScreens((current) => ({
299 ...current,
300 routes: current.routes.filter((route) => route.key !== key),
301 completed: new Set([...current.completed, key]),
302 }));
303 if (native) {
304 const state = props.navigation.getState();
305 const action = nativeWorkspacePopAction(state, key);
306 if (action) props.navigation.dispatch(action);
307 }
308 };
309 const followCollapsedSplit = (
310 type: "willAppear" | "didAppear" | "willDisappear",
311 key: string,
312 ) => {
313 if (!collapsed.current || layout.usesSplitView) return;
314 const transition = collapsedSplitTransition(
315 collapsedColumn.current,
316 { type, key },
317 props.navigation.getState(),
318 props.primary.key,
319 );
320 collapsedColumn.current = transition.column;
321 setNativelyPoppingWorkspaceRoute(transition.column.leaving);
322 if (transition.pop) props.navigation.dispatch(transition.pop);
323 };
324​
325 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells the native split host which column to show while collapsed
326 useEffect(() => {
327 if (layout.usesSplitView || collapsedColumn.current.visible === compactColumn) return;
328 collapsedColumn.current = { ...collapsedColumn.current, visible: compactColumn };
329 hostRef.current?.show(compactColumn);
330 }, [compactColumn, layout.usesSplitView]);
331​
332 const primary = props.descriptors[props.primary.key];
333 if (!primary) return null;
334 return (
335 <Split.Host
336 ref={hostRef}
337 testID="adaptive-workspace-layout"
338 preferredSplitBehavior={
339 !sidebarByOrientation && layout.variant === "medium" ? "overlay" : "tile"
340 }
341 portraitPreferredSplitBehavior={sidebarByOrientation ? "overlay" : undefined}
342 portraitPreferredDisplayMode={
343 sidebarByOrientation ? sidebarDisplayMode(sidebarByOrientation.portrait, true) : undefined
344 }
345 primaryBackgroundStyle={deviceType === "pad" ? "none" : "default"}
346 topColumnForCollapsing={compactColumn}
347 onCollapse={() => {
348 collapsed.current = true;
349 collapsedColumn.current = { visible: compactColumn, leaving: null };
350 setNativelyPoppingWorkspaceRoute(null);
351 }}
352 onExpand={() => {
353 collapsed.current = false;
354 setNativelyPoppingWorkspaceRoute(null);
355 // Native callbacks can outlive the render that created them, so decide from the live
356 // router state. An expanded split has columns, so its tab gets a page beside the list.
357 const live = props.navigation.getState();
358 const { workspace } = workspaceRoutesOf(live.routes.slice(0, live.index + 1));
359 if (workspace.some((route) => route.name !== "Home" && route.name !== "Settings")) return;
360 const tab = workspace.some((route) => route.name === "Settings")
361 ? "settings"
362 : (["threads", "pullRequests", "usage"] as const).find((candidate) =>
363 workspaceTabs?.isTabActive(candidate),
364 );
365 openWorkspaceTab(tab ?? "threads", { columns: true });
366 }}
367 preferredDisplayMode={
368 sidebarByOrientation
369 ? sidebarDisplayMode(sidebarByOrientation.landscape, false)
370 : sidebarDisplayMode(
371 // UIKit applies the mode as a collapsed split expands, before the new width reaches
372 // JS, so a collapsed split already carries the sidebar the expanded one will show.
373 (layout.usesSplitView
374 ? panes.primarySidebarVisible
375 : panes.primarySidebarWhenExpanded) ||
376 (!layout.usesSplitView && !activeDetailKey),
377 layout.variant === "medium",
378 )
379 }
380 onDisplayModeWillChange={({ nativeEvent }) => {
381 if (layout.variant !== "medium") return;
382 if (nativeEvent.nextDisplayMode === "oneOverSecondary") setPrimarySidebarVisible(true);
383 else if (nativeEvent.nextDisplayMode === "secondaryOnly") setPrimarySidebarVisible(false);
384 }}
385 displayModeButtonVisibility="never"
386 presentsWithGesture
387 showInspector={inspector.visible}
388 onInspectorHide={hideInspector}
389 columnMetrics={{
390 minimumPrimaryColumnWidth: 280,
391 maximumPrimaryColumnWidth: Math.max(380, layout.listPaneWidth ?? 0),
392 preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320,
393 minimumSecondaryColumnWidth: NATIVE_INSPECTOR_MIN_MAIN_WIDTH,
394 // UIKit can retain its automatic width despite a new preference; constrain both edges.
395 minimumInspectorColumnWidth: inspectorWidth,
396 maximumInspectorColumnWidth: inspectorWidth,
397 preferredInspectorColumnWidthOrFraction: inspectorWidth,
398 }}
399 >
400 <Split.Column>
401 <Split.Stack>
402 <ColumnScreen
403 descriptor={primary}
404 primary
405 footer={
406 deviceType === "pad" ? (
407 <View
408 pointerEvents="box-none"
409 style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 128 }}
410 >
411 <WorkspaceSidebarTabsFooter />
412 </View>
413 ) : undefined
414 }
415 selectedThreadKey={selectedThreadKey}
416 selectedPullRequestKey={selectedPullRequestKey}
417 onDismiss={handleNativeDismiss}
418 onAppearance={followCollapsedSplit}
419 navigation={props.navigation}
420 />
421 </Split.Stack>
422 </Split.Column>
423 <Split.Column>
424 <Split.Stack>
425 <Split.Screen
426 screenKey="workspace-empty"
427 activityMode={layout.usesSplitView && !activeDetailKey ? "attached" : "detached"}
428 >
429 <PaneContext value="content">
430 <EmptyDetailHeader onCompose={detailCompose} />
431 </PaneContext>
432 <ColumnContent>
433 <WorkspaceEmptyDetail
434 tab={settingsOpen ? "settings" : selectedTab}
435 onStartNewTask={
436 // Duo provides compose in the detail bar; iPad uses the empty-state action.
437 detailCompose ? undefined : openNewTask
438 }
439 />
440 </ColumnContent>
441 </Split.Screen>
442 {screens.routes.map((route) => {
443 const descriptor = props.descriptors[route.key] ?? screens.descriptors[route.key];
444 const inSettingsSlot = settingsDetailIn(props.detail)?.key === route.key;
445 const screenKey = inSettingsSlot ? SETTINGS_DETAIL_SCREEN : route.key;
446 return descriptor ? (
447 <ColumnScreen
448 key={screenKey}
449 screenKey={screenKey}
450 descriptor={descriptor}
451 attached={props.detail.some((current) => current.key === route.key)}
452 onDismiss={handleNativeDismiss}
453 onAppearance={followCollapsedSplit}
454 onCompose={isPullRequestRoute(route) ? undefined : detailCompose}
455 navigation={props.navigation}
456 />
457 ) : null;
458 })}
459 </Split.Stack>
460 </Split.Column>
461 <InspectorColumnScreens
462 content={inspector.content}
463 onClose={hideInspector}
464 overlay={
465 inspector.visible ? (
466 <GestureHandlerRootView
467 style={[StyleSheet.absoluteFill, { zIndex: 100 }]}
468 pointerEvents="box-none"
469 collapsable={false}
470 >
471 <SafeAreaView style={{ flex: 1 }} edges={["left", "right"]} pointerEvents="box-none">
472 <View className="flex-1" pointerEvents="box-none">
473 <View
474 className="absolute inset-y-0 left-0 w-[22px] flex-row"
475 pointerEvents="box-none"
476 >
477 <WorkspacePaneDivider
478 accessibilityLabel="Resize inspector"
479 currentWidth={inspectorWidth}
480 resizeDirection={-1}
481 onResizeStart={() => {
482 resizeStartWidth.current = inspectorWidth;
483 }}
484 onResizeBy={(delta) =>
485 setNativeInspectorWidth(resizeStartWidth.current + delta)
486 }
487 />
488 </View>
489 </View>
490 </SafeAreaView>
491 </GestureHandlerRootView>
492 ) : null
493 }
494 />
495 </Split.Host>
496 );
498​
499function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean }) {
500 const { layout } = useAdaptiveWorkspaceLayout();
501 const deviceType = useDeviceType();
502 const workspace = useWorkspaceTabs()!;
503 // Native dismiss commits the route pop after the transition. Restore the dock
504 // when the primary screen starts appearing, including interactive back gestures.
505 const [primaryVisible, setPrimaryVisible] = useState(false);
506 const metrics = useNativeLayoutMetrics();
507 const projection = projectWorkspaceStack(props.state, (route) => {
508 return isPresentedRoute(props.descriptors[route.key]?.options);
509 });
510 // The root's willDisappear reaches JS frames into the push, so hide the dock in the commit that
511 // pushes; the native events still own every later change, including back gestures.
512 const topDetailKey = projection.detail.at(-1)?.key;
513 const [pushedDetailKey, setPushedDetailKey] = useState(topDetailKey);
514 if (pushedDetailKey !== topDetailKey) {
515 setPushedDetailKey(topDetailKey);
516 if (topDetailKey !== undefined && primaryVisible) setPrimaryVisible(false);
517 }
518 // Detail-only histories must stay single-stack: placeholder descriptors cannot own sidebar actions or setOptions.
519 const primaryRoute = projection.primary;
520 if (!primaryRoute) return <V5StackView {...props} />;
521 const primaryDescriptor = props.descriptors[primaryRoute.key]!;
522 const descriptors = props.descriptors;
523 const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" };
524 const visibleRoute = projection.detail.at(-1) ?? primaryRoute;
525 const workspacePage = !workspaceUsesColumns(workspace.state.selectedTab, workspace.settingsOpen);
526 const baseDescriptor: Descriptor = {
527 ...primaryDescriptor,
528 navigation: props.descriptors[visibleRoute.key]!.navigation,
529 // Identify the real visible route under the stable envelope key, or useIsFocused closes media viewers.
530 route: visibleRoute,
531 options: { headerShown: false },
532 render: () => (
533 <WorkspaceNativeTabs
534 tabsInSidebarColumn={props.nativeColumns && !workspacePage && deviceType === "pad"}
535 hidden={
536 !workspacePage &&
537 projection.detail.length > 0 &&
538 !primaryVisible &&
539 !layout.usesSplitView &&
540 (metrics?.verticalBarEdge ?? "none") === "none"
541 }
542 >
543 {props.nativeColumns && !workspacePage ? (
544 <WorkspaceColumns
545 {...props}
546 descriptors={descriptors}
547 primary={primaryRoute}
548 detail={projection.detail}
549 />
550 ) : (
551 <V5CardStackView
552 {...props}
553 onRootVisibilityChange={setPrimaryVisible}
554 onNativePopChange={setNativelyPoppingWorkspaceRoute}
555 descriptors={descriptors}
556 state={{
557 ...props.state,
558 routes: [primaryRoute, ...projection.detail],
559 index: projection.detail.length,
560 preloadedRoutes: [],
561 }}
562 />
563 )}
564 </WorkspaceNativeTabs>
565 ),
566 };
567 const overlays = partitionStackPresentations(projection.overlays, (route) => {
568 return isPresentedRoute(props.descriptors[route.key]?.options);
569 });
570 const routes = [baseRoute, ...overlays.map((group) => group[0]!)];
571 return (
572 <NativeStackView
573 presentationEnvelope
574 {...props}
575 state={{ ...props.state, routes, index: routes.length - 1, preloadedRoutes: [] }}
576 descriptors={{
577 ...Object.fromEntries(
578 overlays.map((group) => {
579 const route = group[0]!;
580 const descriptor = props.descriptors[route.key]!;
581 const visibleRoute = group.at(-1)!;
582 return [
583 route.key,
584 {
585 ...descriptor,
586 navigation: props.descriptors[visibleRoute.key]!.navigation,
587 route: visibleRoute,
588 options: modalEnvelopeOptions(descriptor.options),
589 // Before iOS 26 the JS columns wrap this whole navigator in the content pane.
590 render: () => (
591 <PaneContext value={null}>
592 <V5CardStackView
593 {...props}
594 state={{
595 ...props.state,
596 routes: group,
597 index: group.length - 1,
598 preloadedRoutes: [],
599 }}
600 />
601 </PaneContext>
602 ),
603 },
604 ];
605 }),
606 ),
607 [baseRoute.key]: baseDescriptor,
608 }}
609 />
610 );
612​
613function WorkspaceStackNavigator({
614 id,
615 initialRouteName,
616 UNSTABLE_routeNamesChangeBehavior,
617 children,
618 layout,
619 screenListeners,
620 screenOptions,
621 screenLayout,
622 UNSTABLE_router,
623 ...rest
624}: NativeStackNavigatorProps) {
625 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
626 const nativeWorkspaceColumnsReady = useNativeWorkspaceColumnsReady();
627 const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<
628 StackNavigationState<ParamListBase>,
629 StackRouterOptions,
630 StackActionHelpers<ParamListBase>,
631 NativeStackNavigationOptions,
632 NativeStackNavigationEventMap
633 >(StackRouter, {
634 id,
635 initialRouteName,
636 UNSTABLE_routeNamesChangeBehavior,
637 children,
638 layout,
639 screenListeners,
640 screenOptions,
641 screenLayout,
642 UNSTABLE_router,
643 });
644 if (!nativeWorkspaceColumnsReady) return null;
645 const settingsOpen = settingsOpenIn(
646 state.routes.slice(0, state.index + 1).filter((route) => {
647 return !isPresentedRoute(descriptors[route.key]?.options);
648 }),
649 );
650 return (
651 <NativeWorkspaceModeContext value={usesNativeWorkspaceColumns}>
652 <NavigationContent>
653 <WorkspaceTabsProvider settingsOpen={settingsOpen}>
654 <WorkspaceStackView
655 nativeColumns={usesNativeWorkspaceColumns}
656 {...rest}
657 state={state}
658 describe={describe}
659 descriptors={descriptors}
660 navigation={navigation}
661 />
662 </WorkspaceTabsProvider>
663 </NavigationContent>
664 </NativeWorkspaceModeContext>
665 );
667​
668type WorkspaceTypeBag<
669 ParamList extends ParamListBase,
670 NavigatorID extends string | undefined,
671> = Omit<NativeStackTypeBag<ParamList, NavigatorID>, "Navigator"> & {
672 Navigator: typeof WorkspaceStackNavigator;
673};
674​
675export function createWorkspaceStackNavigator<
676 const ParamList extends ParamListBase,
677 const NavigatorID extends string | undefined = string | undefined,
678 const TypeBag extends NavigatorTypeBagBase = WorkspaceTypeBag<ParamList, NavigatorID>,
679 const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
680>(config?: Config): TypedNavigator<TypeBag, Config> {
681 return createNavigatorFactory(WorkspaceStackNavigator)(config);
683​
684function sidebarDisplayMode(visible: boolean, overlay: boolean) {
685 if (!visible) return "secondaryOnly";
686 return overlay ? "oneOverSecondary" : "oneBesideSecondary";