| 1 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 2 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 3 | import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; |
| 4 | import { AsyncResult } from "effect/reactivity"; |
| 5 | import { useRef, useState } from "react"; |
| 6 | import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; |
| 7 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 9 | import { ControlPillMenu } from "../../components/ControlPillMenu"; |
| 10 | import { MaterialButton } from "../../components/MaterialButton"; |
| 11 | import { MaterialIconButton } from "../../components/MaterialIconButton"; |
| 12 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 13 | import { composerPullRequests } from "../../state/pull-requests"; |
| 14 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 15 | import { |
| 16 | allowedMergeMethods, |
| 17 | canPerformPullRequestAction, |
| 18 | mergeUnavailableReason, |
| 19 | MERGE_CONFLICT_REASON, |
| 20 | MERGE_METHODS, |
| 21 | } from "./pullRequestActions"; |
| 22 | import { pullRequestStackMerge, singlePullRequestMergeAllowed } from "./pullRequestStack"; |
| 23 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 24 | import { usePullRequestPermissions } from "./usePullRequestPermissions"; |
| 25 | import type { PullRequestStackState } from "./usePullRequestStack"; |
| 26 | |
| 27 | export function PullRequestMergeControls(props: { |
| 28 | readonly pr: PullRequestDetail; |
| 29 | readonly params: PullRequestParams; |
| 30 | readonly stack: PullRequestStackState; |
| 31 | }) { |
| 32 | const { pr, stack } = props; |
| 33 | const target = pullRequestTarget(props.params); |
| 34 | const permissions = usePullRequestPermissions(props.params, pr); |
| 35 | const preferences = useAtomValue(mobilePreferencesAtom); |
| 36 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 37 | const runAction = useAtomCommand(composerPullRequests.runAction, { reportFailure: false }); |
| 38 | const refreshDetail = useAtomRefresh(composerPullRequests.detail(target)); |
| 39 | const refreshActivity = useAtomRefresh(composerPullRequests.activity(target)); |
| 40 | const [selectedMethod, setSelectedMethod] = useState<PullRequestMergeMethod | null>(null); |
| 41 | const [busy, setBusy] = useState(false); |
| 42 | const pending = useRef(false); |
| 43 | const methods = allowedMergeMethods(pr); |
| 44 | const preferred = |
| 45 | selectedMethod ?? |
| 46 | (AsyncResult.isSuccess(preferences) ? preferences.value.pullRequestMergeMethod : undefined) ?? |
| 47 | "squash"; |
| 48 | const method = methods.includes(preferred) |
| 49 | ? preferred |
| 50 | : methods.includes("squash") |
| 51 | ? "squash" |
| 52 | : methods[0]; |
| 53 | const scope = stack.stack ? pullRequestStackMerge(stack.stack, pr.number) : null; |
| 54 | const canMergeSingle = singlePullRequestMergeAllowed({ |
| 55 | supportsStackActions: stack.supportsStackActions, |
| 56 | hasStack: stack.stack !== null, |
| 57 | isFresh: stack.isFresh, |
| 58 | }); |
| 59 | const checkingStack = |
| 60 | permissions.connected && stack.supportsStackActions && !stack.isFresh && !stack.error; |
| 61 | const reason = |
| 62 | (!permissions.connected |
| 63 | ? "Reconnect to merge." |
| 64 | : mergeUnavailableReason(pr, permissions.canWrite)) ?? |
| 65 | (stack.stack |
| 66 | ? !stack.supportsStackActions |
| 67 | ? "This environment can display stacks but cannot merge them." |
| 68 | : !stack.isFresh |
| 69 | ? "Refresh the stack before merging." |
| 70 | : scope?.reason |
| 71 | : !canMergeSingle |
| 72 | ? stack.error |
| 73 | ? "Couldn't check stack membership. Refresh before merging." |
| 74 | : "Checking stack membership…" |
| 75 | : null); |
| 76 | const canMerge = !busy && method !== undefined && !reason; |
| 77 | const label = method |
| 78 | ? scope && scope.layers.length > 1 |
| 79 | ? `${MERGE_METHODS[method].title} stack (${scope.layers.length})` |
| 80 | : MERGE_METHODS[method].title |
| 81 | : "Merge unavailable"; |
| 82 | const autoAction = pr.autoMergeEnabled ? "disable-auto-merge" : "enable-auto-merge"; |
| 83 | const canAutoMerge = |
| 84 | !busy && |
| 85 | (autoAction === "disable-auto-merge" || (!stack.stack && canMergeSingle)) && |
| 86 | permissions.canWrite && |
| 87 | canPerformPullRequestAction(pr, autoAction); |
| 88 | |
| 89 | const perform = async (action: "merge" | "enable-auto-merge" | "disable-auto-merge") => { |
| 90 | if ( |
| 91 | pending.current || |
| 92 | (action !== "disable-auto-merge" && !method) || |
| 93 | (action === "merge" ? !canMerge : !canAutoMerge) |
| 94 | ) |
| 95 | return; |
| 96 | pending.current = true; |
| 97 | setBusy(true); |
| 98 | try { |
| 99 | const response = await runAction({ |
| 100 | ...target, |
| 101 | input: { |
| 102 | ...target.input, |
| 103 | action, |
| 104 | ...(action === "merge" && method && stack.stack && scope |
| 105 | ? { |
| 106 | stackNumber: stack.stack.number, |
| 107 | expectedStackHeads: scope.heads, |
| 108 | mergeMethod: method, |
| 109 | } |
| 110 | : action === "merge" && method |
| 111 | ? { |
| 112 | resolveMergeMethod: (fresh) => { |
| 113 | if ( |
| 114 | !canPerformPullRequestAction(fresh, "merge") || |
| 115 | !allowedMergeMethods(fresh).includes(method) |
| 116 | ) |
| 117 | throw new Error("The pull request changed. Refresh before merging."); |
| 118 | return method; |
| 119 | }, |
| 120 | } |
| 121 | : action === "enable-auto-merge" && method |
| 122 | ? { mergeMethod: method } |
| 123 | : {}), |
| 124 | }, |
| 125 | }); |
| 126 | if (response._tag === "Failure") throw squashAtomCommandFailure(response); |
| 127 | } catch (cause) { |
| 128 | Alert.alert( |
| 129 | "Couldn't complete the merge action", |
| 130 | cause instanceof Error ? cause.message : "Try again.", |
| 131 | ); |
| 132 | } finally { |
| 133 | // A stack can partly merge before the provider reports a failure. |
| 134 | refreshDetail(); |
| 135 | refreshActivity(); |
| 136 | stack.refresh(); |
| 137 | pending.current = false; |
| 138 | setBusy(false); |
| 139 | } |
| 140 | }; |
| 141 | const confirm = (action: "merge" | "enable-auto-merge" | "disable-auto-merge") => { |
| 142 | const title = |
| 143 | action === "merge" |
| 144 | ? scope && scope.layers.length > 1 |
| 145 | ? `Merge ${scope.layers.length} stack layers?` |
| 146 | : "Merge pull request?" |
| 147 | : action === "enable-auto-merge" |
| 148 | ? "Enable auto-merge?" |
| 149 | : "Disable auto-merge?"; |
| 150 | const message = |
| 151 | action === "merge" && scope |
| 152 | ? `Merge ${scope.layers.map((layer) => `#${layer.number}`).join(" → ")} into ${stack.stack?.base} using ${method}. Layers above #${pr.number} stay open.` |
| 153 | : action === "enable-auto-merge" |
| 154 | ? `Merge #${pr.number} using ${method} when its requirements are met. This may happen immediately.` |
| 155 | : action === "disable-auto-merge" |
| 156 | ? "Stop the provider from automatically merging this pull request." |
| 157 | : `Merge #${pr.number} into ${pr.baseBranch} using ${method}.`; |
| 158 | Alert.alert(title, message, [ |
| 159 | { text: "Cancel", style: "cancel" }, |
| 160 | { |
| 161 | text: action === "merge" ? label : title.replace("?", ""), |
| 162 | onPress: () => void perform(action), |
| 163 | }, |
| 164 | ]); |
| 165 | }; |
| 166 | return ( |
| 167 | <View className="gap-2 border-t border-border-subtle p-3"> |
| 168 | <View |
| 169 | className={ |
| 170 | Platform.OS === "android" |
| 171 | ? "flex-row items-center gap-2" |
| 172 | : `flex-row overflow-hidden rounded-xl border-continuous ${canMerge ? "bg-prominent-action" : "bg-subtle-strong"}` |
| 173 | } |
| 174 | > |
| 175 | {Platform.OS === "android" ? ( |
| 176 | <View className="min-w-0 flex-1"> |
| 177 | <MaterialButton |
| 178 | label={busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label} |
| 179 | tone="primary" |
| 180 | fullWidth |
| 181 | disabled={!canMerge} |
| 182 | loading={busy || checkingStack} |
| 183 | onPress={() => confirm("merge")} |
| 184 | /> |
| 185 | </View> |
| 186 | ) : ( |
| 187 | <Pressable |
| 188 | accessibilityRole="button" |
| 189 | accessibilityLabel={ |
| 190 | scope ? `${label}, ${scope.layers.length} stack layers through #${pr.number}` : label |
| 191 | } |
| 192 | accessibilityState={{ disabled: !canMerge, busy: busy || checkingStack }} |
| 193 | disabled={!canMerge} |
| 194 | onPress={() => confirm("merge")} |
| 195 | className="min-h-11 min-w-0 flex-1 flex-row items-center justify-center gap-2 px-3 py-2 active:opacity-70" |
| 196 | > |
| 197 | {busy || checkingStack ? ( |
| 198 | <ActivityIndicator size="small" colorClassName="accent-icon-muted" /> |
| 199 | ) : ( |
| 200 | <SymbolView |
| 201 | name="arrow.triangle.merge" |
| 202 | size={16} |
| 203 | tintColorClassName={ |
| 204 | canMerge ? "accent-prominent-action-foreground" : "accent-icon-muted" |
| 205 | } |
| 206 | /> |
| 207 | )} |
| 208 | <Text |
| 209 | className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-prominent-action-foreground" : "text-foreground-muted"}`} |
| 210 | > |
| 211 | {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label} |
| 212 | </Text> |
| 213 | </Pressable> |
| 214 | )} |
| 215 | {methods.length > 0 || canAutoMerge ? ( |
| 216 | <ControlPillMenu |
| 217 | title="Merge options" |
| 218 | actions={[ |
| 219 | ...methods.map((id) => ({ |
| 220 | id, |
| 221 | title: MERGE_METHODS[id].title, |
| 222 | state: id === method ? ("on" as const) : ("off" as const), |
| 223 | attributes: { disabled: busy }, |
| 224 | })), |
| 225 | ...(canAutoMerge |
| 226 | ? [ |
| 227 | { |
| 228 | id: autoAction, |
| 229 | title: pr.autoMergeEnabled ? "Disable auto-merge" : "Enable auto-merge", |
| 230 | image: "clock", |
| 231 | }, |
| 232 | ] |
| 233 | : []), |
| 234 | ]} |
| 235 | onPressAction={({ nativeEvent }) => { |
| 236 | const selected = methods.find((id) => id === nativeEvent.event); |
| 237 | if (selected) { |
| 238 | setSelectedMethod(selected); |
| 239 | savePreferences({ pullRequestMergeMethod: selected }); |
| 240 | } else if (nativeEvent.event === autoAction && canAutoMerge) confirm(autoAction); |
| 241 | }} |
| 242 | > |
| 243 | {Platform.OS === "android" ? ( |
| 244 | <View pointerEvents="none"> |
| 245 | <MaterialIconButton |
| 246 | accessibilityLabel="Merge options" |
| 247 | icon="chevron.down" |
| 248 | variant="tonal" |
| 249 | /> |
| 250 | </View> |
| 251 | ) : ( |
| 252 | <Pressable |
| 253 | accessibilityRole="button" |
| 254 | accessibilityLabel="Merge options" |
| 255 | className="min-h-11 w-11 items-center justify-center border-l border-border-subtle" |
| 256 | > |
| 257 | <SymbolView |
| 258 | name="chevron.down" |
| 259 | size={13} |
| 260 | tintColorClassName={ |
| 261 | canMerge ? "accent-prominent-action-foreground" : "accent-icon-muted" |
| 262 | } |
| 263 | /> |
| 264 | </Pressable> |
| 265 | )} |
| 266 | </ControlPillMenu> |
| 267 | ) : null} |
| 268 | </View> |
| 269 | {permissions.connected && |
| 270 | reason && |
| 271 | reason !== MERGE_CONFLICT_REASON && |
| 272 | reason !== "Checking stack membership…" && |
| 273 | !(checkingStack && reason === "Refresh the stack before merging.") ? ( |
| 274 | <Text className="px-1 text-xs text-foreground-muted">{reason}</Text> |
| 275 | ) : null} |
| 276 | {pr.autoMergeEnabled ? ( |
| 277 | <Text className="px-1 text-xs text-foreground-muted"> |
| 278 | Auto-merge is enabled{pr.autoMergeMethod ? ` · ${pr.autoMergeMethod}` : ""} |
| 279 | </Text> |
| 280 | ) : null} |
| 281 | {stack.error && stack.supportsStackActions ? ( |
| 282 | <Pressable |
| 283 | accessibilityRole="button" |
| 284 | onPress={stack.refresh} |
| 285 | className="min-h-11 justify-center px-1" |
| 286 | > |
| 287 | <Text className="text-sm text-primary-text">Retry stack lookup</Text> |
| 288 | </Pressable> |
| 289 | ) : null} |
| 290 | </View> |
| 291 | ); |
| 292 | } |