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 { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
23import { Platform, View } from "react-native";
24import { Split, type SplitHostCommands } from "react-native-screens";
25import { EnvironmentId, ThreadId } from "@t3tools/contracts";
26import { scopedThreadKey } from "../../lib/scopedEntities";
27import { WorkspaceTabsProvider } from "../workspace/WorkspaceTabsContext";
28import { WorkspaceNativeTabs } from "../workspace/WorkspaceNativeTabs.ios";
29import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
30
31import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout";
32import { WorkspaceEmptyDetail } from "./WorkspaceEmptyDetail";
33import { NativeColumnContent as ColumnContent } from "../../native/NativeColumnContent.ios";
34import {
35 modalEnvelopeOptions,
36 V5CardStackView,
37 V5StackView,
38} from "../../native/createV5StackNavigator.ios";
39import {
40 useNativeWorkspaceColumnsReady,
41 useNativeWorkspaceColumnsSupported,
42} from "../../native/NativeWorkspaceColumns";
43import { V5StackHeader } from "../../native/V5StackHeader.ios";
44import type { AppNativeStackNavigationOptions } from "../../native/StackHeader";
45import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
46import {
47 nativeWorkspacePopAction,
48 projectWorkspaceStack,
49 partitionStackPresentations,
50} from "../../native/workspace-stack-projection";
51import {
52 NativePrimaryColumnContext,
53 NativeWorkspaceInspectorContext,
54 NativeWorkspaceModeContext,
55} from "../../native/v5-workspace-context";
56
57type ViewProps = ComponentProps<typeof NativeStackView>;
58type Descriptor = ViewProps["descriptors"][string];
59type Route = ViewProps["state"]["routes"][number];
60
61function 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
123function 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
288function 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
395function 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
445type WorkspaceTypeBag<
446 ParamList extends ParamListBase,
447 NavigatorID extends string | undefined,
448> = Omit<NativeStackTypeBag<ParamList, NavigatorID>, "Navigator"> & {
449 Navigator: typeof WorkspaceStackNavigator;
450};
451
452export 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}