1import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2import Animated, {
3 runOnJS,
4 useAnimatedStyle,
5 useSharedValue,
6 withTiming,
7 type SharedValue,
8} from "react-native-reanimated";
9
10import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout";
11import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
12import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
13import { WorkspacePaneDivider } from "./workspace-pane-divider";
14
15/**
16 * The trailing inspector column: resize divider + animated reveal.
17 *
18 * Rendered by AdaptiveWorkspaceLayout as a SIBLING of the navigator so the
19 * native stack header (and its trailing toolbar items) spans only the content
20 * pane — the inspector owns its own full-height column, mirroring how each
21 * column of a UISplitViewController has its own chrome.
22 *
23 * Receives the pane layout via props (not the workspace context hook) so this
24 * module stays import-cycle-free with AdaptiveWorkspaceLayout.
25 */
26export function WorkspaceInspectorPane(props: {
27 readonly pathname: string;
28 readonly renderedInspectorWidth: SharedValue<number>;
29 /**
30 * When false the pane animates closed but keeps its content mounted for the
31 * exit transition (a route that lost focus). `onClosed` fires once the
32 * close animation settles so the owner can drop the stale content.
33 */
34 readonly active?: boolean;
35 readonly onClosed?: () => void;
36 readonly panes: WorkspacePaneLayout;
37 readonly renderInspector?: () => ReactNode;
38 readonly setAuxiliaryPaneWidth: (width: number) => void;
39}) {
40 const { panes, setAuxiliaryPaneWidth } = props;
41 const inspectorWidth = panes.auxiliaryPaneWidth;
42 const inspectorSupported = props.renderInspector !== undefined && inspectorWidth !== null;
43 const inspectorVisible =
44 inspectorSupported && panes.auxiliaryPaneVisible && (props.active ?? true);
45 const resizeStartWidth = useRef(0);
46 const [resizing, setResizing] = useState(false);
47
48 // A file-to-file replace remounts the route. Initialize an already-visible
49 // inspector at its final position so route replacement never replays an
50 // entering transition. Only visibility and explicit resizing change it.
51 const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0);
52 const { renderedInspectorWidth } = props;
53 // The content keeps its own width so the reveal (outer width) clips a
54 // fully-laid-out pane instead of reflowing text every frame. When the OPEN
55 // pane's target width changes (e.g. the sidebar toggles and reserves
56 // space), animate the content width in lockstep rather than snapping.
57 const renderedContentWidth = useSharedValue(inspectorWidth ?? 0);
58
59 const onClosed = props.onClosed;
60 useEffect(() => {
61 inspectorProgress.value = withTiming(
62 inspectorVisible ? 1 : 0,
63 WORKSPACE_PANE_TIMING,
64 (finished) => {
65 if (finished === true && !inspectorVisible && onClosed !== undefined) {
66 runOnJS(onClosed)();
67 }
68 },
69 );
70 const targetWidth = inspectorVisible ? (inspectorWidth ?? 0) : 0;
71 renderedInspectorWidth.value = resizing
72 ? targetWidth
73 : withTiming(targetWidth, WORKSPACE_PANE_TIMING);
74 }, [
75 inspectorProgress,
76 inspectorVisible,
77 inspectorWidth,
78 onClosed,
79 renderedInspectorWidth,
80 resizing,
81 ]);
82
83 useEffect(() => {
84 const targetWidth = inspectorWidth ?? 0;
85 if (!inspectorVisible || resizing) {
86 // Hidden panes re-measure silently; during a divider drag the content
87 // tracks the finger directly.
88 renderedContentWidth.value = targetWidth;
89 return;
90 }
91 renderedContentWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
92 }, [inspectorVisible, inspectorWidth, renderedContentWidth, resizing]);
93
94 const inspectorStyle = useAnimatedStyle(
95 () => ({
96 opacity: inspectorProgress.value,
97 transform: [{ translateX: (1 - inspectorProgress.value) * 24 }],
98 width: renderedInspectorWidth.value,
99 }),
100 [],
101 );
102 const inspectorContentStyle = useAnimatedStyle(() => ({ width: renderedContentWidth.value }), []);
103 const beginResize = useCallback(() => {
104 resizeStartWidth.current = inspectorWidth ?? 0;
105 setResizing(true);
106 }, [inspectorWidth]);
107 const resizeBy = useCallback(
108 (delta: number) => {
109 setAuxiliaryPaneWidth(
110 constrainAuxiliaryPaneWidth({
111 preferredWidth: resizeStartWidth.current + delta,
112 availableWidth: panes.contentPaneWidth,
113 }),
114 );
115 },
116 [panes.contentPaneWidth, setAuxiliaryPaneWidth],
117 );
118 const endResize = useCallback(() => {
119 setResizing(false);
120 }, []);
121
122 return (
123 <>
124 {inspectorVisible ? (
125 <WorkspacePaneDivider
126 accessibilityLabel="Resize detail pane"
127 currentWidth={inspectorWidth ?? 0}
128 resizeDirection={-1}
129 onResizeStart={beginResize}
130 onResizeBy={resizeBy}
131 onResizeEnd={endResize}
132 />
133 ) : null}
134 {inspectorSupported ? (
135 <Animated.View
136 className="shrink-0 overflow-hidden"
137 accessibilityElementsHidden={!inspectorVisible}
138 collapsable={false}
139 importantForAccessibility={inspectorVisible ? "auto" : "no-hide-descendants"}
140 pointerEvents={inspectorVisible ? "auto" : "none"}
141 style={inspectorStyle}
142 >
143 <Animated.View className="flex-1" style={inspectorContentStyle}>
144 <RenderErrorBoundary
145 resetKeys={[props.pathname]}
146 renderFallback={(fallback) => (
147 <RenderFailureView {...fallback} title="The inspector couldn't be displayed" />
148 )}
149 >
150 <InspectorRenderer render={props.renderInspector} />
151 </RenderErrorBoundary>
152 </Animated.View>
153 </Animated.View>
154 ) : null}
155 </>
156 );
157}
158
159// The render callback must run inside the boundary's child, not while its
160// parent constructs the boundary element.
161function InspectorRenderer(props: { readonly render?: () => ReactNode }) {
162 return <>{props.render?.()}</>;
163}