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