1import Animated from "react-native-reanimated";
2import { PR_ROW_LAYOUT } from "./pullRequestAnimations";
3import { useNavigation } from "@react-navigation/native";
4import type { PullRequestDetail } from "@t3tools/contracts";
5import type { ReactNode } from "react";
6import { Platform, Pressable, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { AppText as Text } from "../../components/AppText";
9import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
10import { MaterialButton } from "../../components/MaterialButton";
11import { MaterialIconButton } from "../../components/MaterialIconButton";
12import { MaterialFloatingToolbar } from "./MaterialFloatingToolbar";
13import { ControlPillMenu } from "../../components/ControlPillMenu";
14import { NativeHeaderToolbar } from "../../native/StackHeader";
15import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
16import { type PullRequestParams } from "./usePullRequestDiff";
17import { usePullRequestPermissions } from "./usePullRequestPermissions";
18import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
19import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft";
20import { usePullRequestAgentActions } from "./usePullRequestAgentActions";
21
22export function PullRequestCardGroup(props: { readonly children: ReactNode }) {
23 return (
24 <Animated.View
25 layout={PR_ROW_LAYOUT}
26 className="overflow-hidden rounded-[22px] border border-composer-border border-continuous bg-grouped-card"
27 >
28 {props.children}
29 </Animated.View>
30 );
31}
32
33export function PullRequestCardRow(props: {
34 readonly title: string;
35 readonly icon: AppSymbolName;
36 readonly tint?: string;
37 readonly value?: string;
38 readonly detail?: string;
39 readonly onPress?: () => void;
40 readonly accessibilityLabel?: string;
41 readonly destructive?: boolean;
42 readonly last?: boolean;
43 readonly compact?: boolean;
44 readonly menu?: boolean;
45 readonly expanded?: boolean;
46 readonly children?: ReactNode;
47}) {
48 return (
49 <Pressable
50 accessibilityRole={props.onPress || props.menu ? "button" : undefined}
51 accessibilityLabel={props.accessibilityLabel}
52 accessibilityState={props.expanded === undefined ? undefined : { expanded: props.expanded }}
53 onPress={props.onPress}
54 disabled={!props.onPress && !props.menu}
55 className={`${props.compact ? "min-h-11 py-2.5" : "min-h-13 py-3"} flex-row items-center gap-3 px-4 ${props.last || props.compact ? "" : "border-b border-border-subtle"} ${props.onPress ? "active:opacity-70" : ""}`}
56 >
57 <SymbolView
58 name={props.icon}
59 size={18}
60 tintColorClassName={
61 props.destructive ? "accent-danger-foreground" : (props.tint ?? "accent-icon-muted")
62 }
63 />
64 <View className="min-w-0 flex-1 gap-0.5">
65 <Text
66 className={
67 props.destructive
68 ? "text-sm font-t3-medium text-danger-foreground"
69 : "text-sm font-t3-medium"
70 }
71 >
72 {props.title}
73 </Text>
74 {props.detail ? (
75 <Text className="text-2xs text-foreground-muted" numberOfLines={2}>
76 {props.detail}
77 </Text>
78 ) : null}
79 </View>
80 {props.children ??
81 (props.value ? (
82 <Text
83 className={`${props.compact ? "max-w-[38%] text-2xs" : "max-w-[60%] text-xs"} shrink text-right text-foreground-muted`}
84 numberOfLines={2}
85 >
86 {props.value}
87 </Text>
88 ) : null)}
89 {props.onPress || props.menu ? (
90 <SymbolView
91 name={
92 props.expanded === undefined
93 ? "chevron.right"
94 : props.expanded
95 ? "chevron.up"
96 : "chevron.down"
97 }
98 size={11}
99 tintColorClassName="accent-icon-muted"
100 />
101 ) : null}
102 </Pressable>
103 );
104}
105
106export function PullRequestActionToolbar(props: {
107 readonly pr: PullRequestDetail | null;
108 readonly params: PullRequestParams;
109 readonly filePath?: string;
110 readonly onOpenFiles?: () => void;
111 readonly onHeightChange?: (height: number) => void;
112}) {
113 const navigation = useNavigation();
114 const { draft } = usePullRequestReviewDraft(props.params);
115 const { themeVariables: colors } = useAppearancePreferences();
116 const insets = useSafeAreaInsets();
117 const nativeMetrics = useNativeLayoutMetrics();
118 const verticalControls = nativeMetrics !== null && nativeMetrics.verticalBarEdge !== "none";
119 const permissions = usePullRequestPermissions(props.params, props.pr);
120 const agent = usePullRequestAgentActions(props.params, props.pr, props.filePath);
121 const canReview = permissions.verdicts.length > 0;
122 const pendingReview = canReview && draft.comments.length > 0;
123 const showAgentControls = agent.canAsk || !permissions.connected;
124 const canOpenThread = agent.threads.length > 0 || showAgentControls;
125 const threadLabel = agent.threads.length ? "Continue thread" : "Work on PR";
126 const primaryLabel = pendingReview ? `Submit review · ${draft.comments.length}` : threadLabel;
127 const chooseThread = agent.threads.length > 1;
128 const agentActions = agent.actions.filter((action) => action.task !== "ask");
129 const open = (mode: "review" | "comment") =>
130 navigation.navigate("PullRequestReview", { ...props.params, mode });
131 const primaryPress = () => (pendingReview ? open("review") : agent.start("ask"));
132 const threadMenuItems = agent.threadActions("ask");
133 const openFiles =
134 props.onOpenFiles ?? (() => navigation.navigate("PullRequestFiles", props.params));
135 if (!props.pr) return null;
136 const primaryDisabled = pendingReview ? false : agent.disabled;
137 const showPrimary = pendingReview || canOpenThread;
138 if (Platform.OS === "android") {
139 const primary = (
140 <MaterialButton
141 label={primaryLabel}
142 tone="primary"
143 disabled={primaryDisabled}
144 onPress={primaryPress}
145 />
146 );
147 return (
148 <View
149 pointerEvents="box-none"
150 className="absolute inset-x-0 bottom-0 z-10 items-center px-4 pt-2"
151 style={{ paddingBottom: insets.bottom + 16 }}
152 onLayout={(event) => props.onHeightChange?.(event.nativeEvent.layout.height)}
153 >
154 <MaterialFloatingToolbar>
155 <View className="flex-row items-center gap-2">
156 {showAgentControls ? (
157 <ControlPillMenu
158 title="AI helpers"
159 actions={agentActions.map((action) => agent.menuAction(action.task))}
160 onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)}
161 >
162 <View pointerEvents="none">
163 <MaterialIconButton
164 icon="sparkles"
165 accessibilityLabel="PR agent actions"
166 disabled={agent.disabled}
167 />
168 </View>
169 </ControlPillMenu>
170 ) : null}
171 {showPrimary ? (
172 <View className="min-w-0 shrink">
173 {!pendingReview && chooseThread ? (
174 <ControlPillMenu
175 title="Open thread"
176 actions={threadMenuItems}
177 onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)}
178 >
179 <View pointerEvents="none">{primary}</View>
180 </ControlPillMenu>
181 ) : (
182 primary
183 )}
184 </View>
185 ) : null}
186 <MaterialIconButton
187 icon="file.diff"
188 accessibilityLabel="Changed files"
189 onPress={openFiles}
190 />
191 </View>
192 </MaterialFloatingToolbar>
193 </View>
194 );
195 }
196 const nativeThreadItems = threadMenuItems.map((item) => (
197 <NativeHeaderToolbar.MenuAction
198 key={item.id}
199 icon={item.image}
200 subtitle={"subtitle" in item ? item.subtitle : undefined}
201 disabled={"attributes" in item && item.attributes.disabled}
202 onPress={() => agent.select(item.id)}
203 >
204 <NativeHeaderToolbar.Label>{item.title}</NativeHeaderToolbar.Label>
205 </NativeHeaderToolbar.MenuAction>
206 ));
207 const primaryControl = showPrimary ? (
208 !pendingReview && chooseThread ? (
209 <NativeHeaderToolbar.Menu
210 identifier="pull-request-primary-action"
211 title={primaryLabel}
212 accessibilityLabel={primaryLabel}
213 variant="prominent"
214 icon={
215 verticalControls
216 ? pendingReview
217 ? "text.bubble"
218 : "bubble.left.and.bubble.right"
219 : undefined
220 }
221 tintColor={colors["--color-prominent-action"]}
222 disabled={primaryDisabled}
223 separateBackground
224 >
225 {nativeThreadItems}
226 </NativeHeaderToolbar.Menu>
227 ) : (
228 <NativeHeaderToolbar.Button
229 identifier="pull-request-primary-action"
230 label={primaryLabel}
231 accessibilityLabel={primaryLabel}
232 variant="prominent"
233 icon={
234 verticalControls
235 ? pendingReview
236 ? "text.bubble"
237 : "bubble.left.and.bubble.right"
238 : undefined
239 }
240 tintColor={colors["--color-prominent-action"]}
241 disabled={primaryDisabled}
242 onPress={primaryPress}
243 separateBackground
244 />
245 )
246 ) : null;
247 const agentControl = showAgentControls ? (
248 <NativeHeaderToolbar.Menu
249 identifier="pull-request-agent"
250 icon="sparkles"
251 title="AI helpers"
252 accessibilityLabel="PR agent actions"
253 disabled={agent.disabled}
254 separateBackground
255 >
256 {agentActions.map((action) =>
257 chooseThread && !action.disabled ? (
258 <NativeHeaderToolbar.Menu key={action.task} title={action.title} icon={action.icon}>
259 {agent.threadActions(action.task).map((item) => (
260 <NativeHeaderToolbar.MenuAction
261 key={item.id}
262 icon={item.image}
263 subtitle={"subtitle" in item ? item.subtitle : undefined}
264 disabled={"attributes" in item && item.attributes.disabled}
265 onPress={() => agent.select(item.id)}
266 >
267 <NativeHeaderToolbar.Label>{item.title}</NativeHeaderToolbar.Label>
268 </NativeHeaderToolbar.MenuAction>
269 ))}
270 </NativeHeaderToolbar.Menu>
271 ) : (
272 <NativeHeaderToolbar.MenuAction
273 key={action.task}
274 icon={action.icon}
275 disabled={action.disabled}
276 onPress={() => agent.start(action.task)}
277 >
278 <NativeHeaderToolbar.Label>{action.title}</NativeHeaderToolbar.Label>
279 </NativeHeaderToolbar.MenuAction>
280 ),
281 )}
282 </NativeHeaderToolbar.Menu>
283 ) : null;
284 // Tabler file-diff geometry, rasterized at 1x/2x/3x for UIKit template images.
285 const filesControl = (
286 <NativeHeaderToolbar.Button
287 identifier="workspace-trailing-action"
288 icon={{ type: "image", source: require("../../../assets/icons/file-diff.png") }}
289 accessibilityLabel="Changed files"
290 onPress={openFiles}
291 separateBackground
292 />
293 );
294 if (verticalControls)
295 return (
296 <NativeHeaderToolbar placement="bottom">
297 {filesControl}
298 {agentControl}
299 {primaryControl}
300 </NativeHeaderToolbar>
301 );
302 return (
303 <NativeHeaderToolbar placement="bottom">
304 {agentControl}
305 <NativeHeaderToolbar.Spacer flexible />
306 {primaryControl}
307 <NativeHeaderToolbar.Spacer flexible />
308 {filesControl}
309 </NativeHeaderToolbar>
310 );
311}