| 1 | import { |
| 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"; |
| 15 | import { |
| 16 | NativeStackView, |
| 17 | type NativeStackNavigationEventMap, |
| 18 | type NativeStackNavigationOptions, |
| 19 | type NativeStackNavigatorProps, |
| 20 | type NativeStackTypeBag, |
| 21 | } from "@react-navigation/native-stack"; |
| 22 | import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; |
| 23 | import { Platform, View } from "react-native"; |
| 24 | import { Split, type SplitHostCommands } from "react-native-screens"; |
| 25 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 26 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 27 | import { WorkspaceTabsProvider } from "../workspace/WorkspaceTabsContext"; |
| 28 | import { WorkspaceNativeTabs } from "../workspace/WorkspaceNativeTabs.ios"; |
| 29 | import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; |
| 30 | |
| 31 | import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; |
| 32 | import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail"; |
| 33 | import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios"; |
| 34 | import { |
| 35 | modalEnvelopeOptions, |
| 36 | V5CardStackView, |
| 37 | V5StackView, |
| 38 | } from "../../native/createV5StackNavigator.ios"; |
| 39 | import { |
| 40 | useNativeWorkspaceColumnsReady, |
| 41 | useNativeWorkspaceColumnsSupported, |
| 42 | } from "../../native/NativeWorkspaceColumns"; |
| 43 | import { V5StackHeader } from "../../native/V5StackHeader.ios"; |
| 44 | import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; |
| 45 | import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 46 | import { |
| 47 | nativeWorkspacePopAction, |
| 48 | projectWorkspaceStack, |
| 49 | partitionStackPresentations, |
| 50 | } from "../../native/workspace-stack-projection"; |
| 51 | import { |
| 52 | NativePrimaryColumnContext, |
| 53 | NativeWorkspaceInspectorContext, |
| 54 | NativeWorkspaceModeContext, |
| 55 | } from "../../native/v5-workspace-context"; |
| 56 | |
| 57 | type ViewProps = ComponentProps<typeof NativeStackView>; |
| 58 | type Descriptor = ViewProps["descriptors"][string]; |
| 59 | type Route = ViewProps["state"]["routes"][number]; |
| 60 | |
| 61 | function ColumnScreen(props: { |
| 62 | readonly descriptor: Descriptor; |
| 63 | readonly primary?: boolean; |
| 64 | readonly selectedThreadKey?: string | null; |
| 65 | readonly selectedPullRequestKey?: string | null; |
| 66 | readonly onDismiss: (routeKey: string) => void; |
| 67 | readonly navigation: ViewProps["navigation"]; |
| 68 | }) { |
| 69 | const { descriptor } = props; |
| 70 | const primaryColumn = useMemo( |
| 71 | () => |
| 72 | props.primary |
| 73 | ? { |
| 74 | selectedThreadKey: props.selectedThreadKey ?? null, |
| 75 | selectedPullRequestKey: props.selectedPullRequestKey ?? null, |
| 76 | } |
| 77 | : null, |
| 78 | [props.primary, props.selectedThreadKey, props.selectedPullRequestKey], |
| 79 | ); |
| 80 | const key = descriptor.route.key; |
| 81 | return ( |
| 82 | <Split.Screen |
| 83 | screenKey={key} |
| 84 | onDismiss={(event) => { |
| 85 | if (event.nativeEvent.isNativeDismiss) props.onDismiss(key); |
| 86 | }} |
| 87 | onWillAppear={() => |
| 88 | props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } }) |
| 89 | } |
| 90 | onDidAppear={() => |
| 91 | props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } }) |
| 92 | } |
| 93 | onWillDisappear={() => |
| 94 | props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } }) |
| 95 | } |
| 96 | onDidDisappear={() => |
| 97 | props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } }) |
| 98 | } |
| 99 | > |
| 100 | <NavigationContext value={descriptor.navigation}> |
| 101 | <NavigationRouteContext value={descriptor.route}> |
| 102 | <V5StackHeader |
| 103 | options={descriptor.options} |
| 104 | canGoBack={!props.primary} |
| 105 | primary={props.primary} |
| 106 | /> |
| 107 | <ColumnContent |
| 108 | primary={props.primary} |
| 109 | insetHorizontally={ |
| 110 | (descriptor.options as AppNativeStackNavigationOptions).nativeContentInsetHorizontally |
| 111 | } |
| 112 | > |
| 113 | <NativePrimaryColumnContext value={primaryColumn}> |
| 114 | {descriptor.render()} |
| 115 | </NativePrimaryColumnContext> |
| 116 | </ColumnContent> |
| 117 | </NavigationRouteContext> |
| 118 | </NavigationContext> |
| 119 | </Split.Screen> |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | function WorkspaceColumns( |
| 124 | props: ViewProps & { readonly primary: Route; readonly detail: Route[] }, |
| 125 | ) { |
| 126 | const { layout, panes, togglePrimarySidebar, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 127 | const inspector = use(NativeWorkspaceInspectorContext); |
| 128 | const hostRef = useRef<SplitHostCommands>(null); |
| 129 | const activeDetailKey = props.detail.at(-1)?.key; |
| 130 | const compactColumn = activeDetailKey ? "secondary" : "primary"; |
| 131 | const shownCompactColumn = useRef(compactColumn); |
| 132 | const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; |
| 133 | const pullRequestParams = props.detail.findLast( |
| 134 | (route) => |
| 135 | route.name === "PullRequest" || |
| 136 | route.name === "PullRequestFile" || |
| 137 | route.name === "PullRequestFiles", |
| 138 | )?.params; |
| 139 | const selectedPullRequestKey = |
| 140 | pullRequestParams && |
| 141 | "environmentId" in pullRequestParams && |
| 142 | "host" in pullRequestParams && |
| 143 | "repository" in pullRequestParams && |
| 144 | "number" in pullRequestParams && |
| 145 | typeof pullRequestParams.environmentId === "string" && |
| 146 | typeof pullRequestParams.host === "string" && |
| 147 | typeof pullRequestParams.repository === "string" && |
| 148 | typeof pullRequestParams.number === "number" |
| 149 | ? `${pullRequestParams.environmentId}:${pullRequestParams.host}:${pullRequestParams.repository}:${pullRequestParams.number}` |
| 150 | : null; |
| 151 | const selectedThreadKey = |
| 152 | threadParams && |
| 153 | "environmentId" in threadParams && |
| 154 | "threadId" in threadParams && |
| 155 | typeof threadParams.environmentId === "string" && |
| 156 | typeof threadParams.threadId === "string" |
| 157 | ? scopedThreadKey( |
| 158 | EnvironmentId.make(threadParams.environmentId), |
| 159 | ThreadId.make(threadParams.threadId), |
| 160 | ) |
| 161 | : null; |
| 162 | const handleNativeDismiss = useCallback( |
| 163 | (key: string) => { |
| 164 | const state = props.navigation.getState(); |
| 165 | const action = nativeWorkspacePopAction(state, key); |
| 166 | if (action) props.navigation.dispatch(action); |
| 167 | }, |
| 168 | [props.navigation], |
| 169 | ); |
| 170 | |
| 171 | useEffect(() => { |
| 172 | // In compact size classes UIKit exposes one column. Selecting a thread |
| 173 | // changes the visible column without rebuilding either navigation stack. |
| 174 | // The initial column is a native prop, so it is applied before the host |
| 175 | // attaches. Commands only handle later navigation changes. |
| 176 | if (layout.usesSplitView || shownCompactColumn.current === compactColumn) return; |
| 177 | shownCompactColumn.current = compactColumn; |
| 178 | hostRef.current?.show(compactColumn); |
| 179 | }, [compactColumn, layout.usesSplitView]); |
| 180 | |
| 181 | const primary = props.descriptors[props.primary.key]; |
| 182 | if (!primary) return null; |
| 183 | return ( |
| 184 | <Split.Host |
| 185 | ref={hostRef} |
| 186 | testID="adaptive-workspace-layout" |
| 187 | preferredSplitBehavior="tile" |
| 188 | topColumnForCollapsing={compactColumn} |
| 189 | preferredDisplayMode={ |
| 190 | panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly" |
| 191 | } |
| 192 | displayModeButtonVisibility="never" |
| 193 | presentsWithGesture |
| 194 | showInspector={inspector.visible} |
| 195 | onInspectorHide={() => { |
| 196 | if (inspector.visible) toggleAuxiliaryPane(); |
| 197 | }} |
| 198 | columnMetrics={{ |
| 199 | minimumPrimaryColumnWidth: 280, |
| 200 | maximumPrimaryColumnWidth: Math.max(380, layout.listPaneWidth ?? 0), |
| 201 | preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, |
| 202 | minimumSecondaryColumnWidth: 320, |
| 203 | }} |
| 204 | > |
| 205 | <Split.Column> |
| 206 | <Split.Stack> |
| 207 | <ColumnScreen |
| 208 | descriptor={primary} |
| 209 | primary |
| 210 | selectedThreadKey={selectedThreadKey} |
| 211 | selectedPullRequestKey={selectedPullRequestKey} |
| 212 | onDismiss={handleNativeDismiss} |
| 213 | navigation={props.navigation} |
| 214 | /> |
| 215 | </Split.Stack> |
| 216 | </Split.Column> |
| 217 | <Split.Column> |
| 218 | <Split.Stack> |
| 219 | <Split.Screen |
| 220 | screenKey="workspace-empty" |
| 221 | activityMode={layout.usesSplitView && !activeDetailKey ? "attached" : "detached"} |
| 222 | > |
| 223 | <V5StackHeader |
| 224 | options={{ |
| 225 | headerShown: true, |
| 226 | title: "", |
| 227 | unstable_headerRightItems: () => |
| 228 | Platform.OS === "ios" && |
| 229 | !Platform.isPad && |
| 230 | layout.usesSplitView && |
| 231 | !panes.primarySidebarVisible |
| 232 | ? [ |
| 233 | { |
| 234 | type: "button", |
| 235 | axisBehavior: "verticalPreferred", |
| 236 | accessibilityLabel: "Search threads", |
| 237 | label: "Search threads", |
| 238 | icon: { type: "sfSymbol", name: "magnifyingglass" }, |
| 239 | onPress: () => { |
| 240 | dispatchHardwareKeyboardCommand("focusSearch"); |
| 241 | }, |
| 242 | }, |
| 243 | ] |
| 244 | : [], |
| 245 | unstable_headerLeftItems: () => [ |
| 246 | { |
| 247 | type: "button", |
| 248 | label: "", |
| 249 | icon: { type: "sfSymbol", name: "sidebar.left" }, |
| 250 | onPress: togglePrimarySidebar, |
| 251 | }, |
| 252 | ], |
| 253 | }} |
| 254 | canGoBack={false} |
| 255 | /> |
| 256 | <ColumnContent> |
| 257 | <WorkspaceEmptyDetail |
| 258 | onStartNewTask={() => |
| 259 | props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }) |
| 260 | } |
| 261 | /> |
| 262 | </ColumnContent> |
| 263 | </Split.Screen> |
| 264 | {props.detail.map((route) => { |
| 265 | const descriptor = props.descriptors[route.key]; |
| 266 | return descriptor ? ( |
| 267 | <ColumnScreen |
| 268 | key={route.key} |
| 269 | descriptor={descriptor} |
| 270 | onDismiss={handleNativeDismiss} |
| 271 | navigation={props.navigation} |
| 272 | /> |
| 273 | ) : null; |
| 274 | })} |
| 275 | </Split.Stack> |
| 276 | </Split.Column> |
| 277 | <Split.Inspector> |
| 278 | {/* Inspector content supplies its own navigation header. */} |
| 279 | <Split.HeaderConfig hidden /> |
| 280 | <ColumnContent> |
| 281 | <View className="flex-1 bg-screen">{inspector.render?.()}</View> |
| 282 | </ColumnContent> |
| 283 | </Split.Inspector> |
| 284 | </Split.Host> |
| 285 | ); |
| 286 | } |
| 287 | |
| 288 | function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean }) { |
| 289 | const { layout } = useAdaptiveWorkspaceLayout(); |
| 290 | // Native dismiss commits the route pop after the transition. Restore the dock |
| 291 | // when the primary screen starts appearing, including interactive back gestures. |
| 292 | const [primaryVisible, setPrimaryVisible] = useState(false); |
| 293 | const metrics = useNativeLayoutMetrics(); |
| 294 | const projection = projectWorkspaceStack(props.state, (route) => { |
| 295 | const presentation = props.descriptors[route.key]?.options.presentation; |
| 296 | return presentation !== undefined && presentation !== "card"; |
| 297 | }); |
| 298 | // Linking normally restores Home via initialRouteName. A detail-only history |
| 299 | // must stay a single stack: placeholder descriptors cannot own an interactive |
| 300 | // sidebar because React Navigation rejects their actions and setOptions. |
| 301 | const primaryRoute = projection.primary; |
| 302 | if (!primaryRoute) return <V5StackView {...props} />; |
| 303 | const primaryDescriptor = props.descriptors[primaryRoute.key]!; |
| 304 | const descriptors = props.descriptors; |
| 305 | const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; |
| 306 | const visibleRoute = projection.detail.at(-1) ?? primaryRoute; |
| 307 | const baseDescriptor: Descriptor = { |
| 308 | ...primaryDescriptor, |
| 309 | navigation: props.descriptors[visibleRoute.key]!.navigation, |
| 310 | // The native envelope keeps a stable key, but its NavigationProvider must |
| 311 | // identify the real visible route or useIsFocused closes media viewers. |
| 312 | route: visibleRoute, |
| 313 | options: { headerShown: false }, |
| 314 | render: () => ( |
| 315 | <WorkspaceNativeTabs |
| 316 | onNewTask={() => props.navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 317 | hidden={ |
| 318 | projection.detail.length > 0 && |
| 319 | !primaryVisible && |
| 320 | !layout.usesSplitView && |
| 321 | (metrics?.verticalBarEdge ?? "none") === "none" |
| 322 | } |
| 323 | onSelect={() => { |
| 324 | if (props.state.index > 0) |
| 325 | props.navigation.dispatch({ type: "POP_TO_TOP", target: props.state.key }); |
| 326 | }} |
| 327 | > |
| 328 | {props.nativeColumns ? ( |
| 329 | <WorkspaceColumns |
| 330 | {...props} |
| 331 | descriptors={descriptors} |
| 332 | primary={primaryRoute} |
| 333 | detail={projection.detail} |
| 334 | /> |
| 335 | ) : ( |
| 336 | <V5CardStackView |
| 337 | {...props} |
| 338 | onRootVisibilityChange={setPrimaryVisible} |
| 339 | descriptors={descriptors} |
| 340 | state={{ |
| 341 | ...props.state, |
| 342 | routes: [primaryRoute, ...projection.detail], |
| 343 | index: projection.detail.length, |
| 344 | preloadedRoutes: [], |
| 345 | }} |
| 346 | /> |
| 347 | )} |
| 348 | </WorkspaceNativeTabs> |
| 349 | ), |
| 350 | }; |
| 351 | const overlays = partitionStackPresentations(projection.overlays, (route) => { |
| 352 | const presentation = props.descriptors[route.key]?.options.presentation; |
| 353 | return presentation !== undefined && presentation !== "card"; |
| 354 | }); |
| 355 | const routes = [baseRoute, ...overlays.map((group) => group[0]!)]; |
| 356 | return ( |
| 357 | <NativeStackView |
| 358 | presentationEnvelope |
| 359 | {...props} |
| 360 | state={{ ...props.state, routes, index: routes.length - 1, preloadedRoutes: [] }} |
| 361 | descriptors={{ |
| 362 | ...Object.fromEntries( |
| 363 | overlays.map((group) => { |
| 364 | const route = group[0]!; |
| 365 | const descriptor = props.descriptors[route.key]!; |
| 366 | const visibleRoute = group.at(-1)!; |
| 367 | return [ |
| 368 | route.key, |
| 369 | { |
| 370 | ...descriptor, |
| 371 | navigation: props.descriptors[visibleRoute.key]!.navigation, |
| 372 | route: visibleRoute, |
| 373 | options: modalEnvelopeOptions(descriptor.options), |
| 374 | render: () => ( |
| 375 | <V5CardStackView |
| 376 | {...props} |
| 377 | state={{ |
| 378 | ...props.state, |
| 379 | routes: group, |
| 380 | index: group.length - 1, |
| 381 | preloadedRoutes: [], |
| 382 | }} |
| 383 | /> |
| 384 | ), |
| 385 | }, |
| 386 | ]; |
| 387 | }), |
| 388 | ), |
| 389 | [baseRoute.key]: baseDescriptor, |
| 390 | }} |
| 391 | /> |
| 392 | ); |
| 393 | } |
| 394 | |
| 395 | function WorkspaceStackNavigator({ |
| 396 | id, |
| 397 | initialRouteName, |
| 398 | UNSTABLE_routeNamesChangeBehavior, |
| 399 | children, |
| 400 | layout, |
| 401 | screenListeners, |
| 402 | screenOptions, |
| 403 | screenLayout, |
| 404 | UNSTABLE_router, |
| 405 | ...rest |
| 406 | }: NativeStackNavigatorProps) { |
| 407 | const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); |
| 408 | const nativeWorkspaceColumnsReady = useNativeWorkspaceColumnsReady(); |
| 409 | const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder< |
| 410 | StackNavigationState<ParamListBase>, |
| 411 | StackRouterOptions, |
| 412 | StackActionHelpers<ParamListBase>, |
| 413 | NativeStackNavigationOptions, |
| 414 | NativeStackNavigationEventMap |
| 415 | >(StackRouter, { |
| 416 | id, |
| 417 | initialRouteName, |
| 418 | UNSTABLE_routeNamesChangeBehavior, |
| 419 | children, |
| 420 | layout, |
| 421 | screenListeners, |
| 422 | screenOptions, |
| 423 | screenLayout, |
| 424 | UNSTABLE_router, |
| 425 | }); |
| 426 | if (!nativeWorkspaceColumnsReady) return null; |
| 427 | return ( |
| 428 | <NativeWorkspaceModeContext value={usesNativeWorkspaceColumns}> |
| 429 | <NavigationContent> |
| 430 | <WorkspaceTabsProvider> |
| 431 | <WorkspaceStackView |
| 432 | nativeColumns={usesNativeWorkspaceColumns} |
| 433 | {...rest} |
| 434 | state={state} |
| 435 | describe={describe} |
| 436 | descriptors={descriptors} |
| 437 | navigation={navigation} |
| 438 | /> |
| 439 | </WorkspaceTabsProvider> |
| 440 | </NavigationContent> |
| 441 | </NativeWorkspaceModeContext> |
| 442 | ); |
| 443 | } |
| 444 | |
| 445 | type WorkspaceTypeBag< |
| 446 | ParamList extends ParamListBase, |
| 447 | NavigatorID extends string | undefined, |
| 448 | > = Omit<NativeStackTypeBag<ParamList, NavigatorID>, "Navigator"> & { |
| 449 | Navigator: typeof WorkspaceStackNavigator; |
| 450 | }; |
| 451 | |
| 452 | export function createWorkspaceStackNavigator< |
| 453 | const ParamList extends ParamListBase, |
| 454 | const NavigatorID extends string | undefined = string | undefined, |
| 455 | const TypeBag extends NavigatorTypeBagBase = WorkspaceTypeBag<ParamList, NavigatorID>, |
| 456 | const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>, |
| 457 | >(config?: Config): TypedNavigator<TypeBag, Config> { |
| 458 | return createNavigatorFactory(WorkspaceStackNavigator)(config); |
| 459 | } |