| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | type GitDialogAction, |
| 12 | 12 | type GitQuickAction, |
| 13 | 13 | requiresDefaultBranchConfirmation, |
| 14 | resolveAutoFeatureBranchName, |
| 14 | 15 | resolveQuickAction, |
| 15 | 16 | summarizeGitResult, |
| 16 | 17 | } from "./GitActionsControl.logic"; |
| ... | ... | @@ -32,6 +33,7 @@ import { Textarea } from "~/components/ui/textarea"; |
| 32 | 33 | import { toastManager } from "~/components/ui/toast"; |
| 33 | 34 | import { |
| 34 | 35 | gitBranchesQueryOptions, |
| 36 | gitCreateBranchAndCheckoutMutationOptions, |
| 35 | 37 | gitInitMutationOptions, |
| 36 | 38 | gitMutationKeys, |
| 37 | 39 | gitPullMutationOptions, |
| ... | ... | @@ -47,6 +49,13 @@ interface GitActionsControlProps { |
| 47 | 49 | activeThreadId: ThreadId | null; |
| 48 | 50 | } |
| 49 | 51 | |
| 52 | interface PendingDefaultBranchAction { |
| 53 | action: GitStackedAction; |
| 54 | commitMessage?: string; |
| 55 | forcePushOnlyProgress: boolean; |
| 56 | onConfirmed?: () => void; |
| 57 | } |
| 58 | |
| 50 | 59 | function getMenuActionDisabledReason( |
| 51 | 60 | item: GitActionMenuItem, |
| 52 | 61 | gitStatus: GitStatusResult | null, |
| ... | ... | @@ -118,6 +127,12 @@ const DIALOG_DESCRIPTION_BY_ACTION = { |
| 118 | 127 | create_pr: "Create a pull request using generated title/body content.", |
| 119 | 128 | }; |
| 120 | 129 | |
| 130 | const DEFAULT_BRANCH_ACTION_DESCRIPTION: Record<GitStackedAction, string> = { |
| 131 | commit: "commit changes", |
| 132 | commit_push: "commit and push changes", |
| 133 | commit_push_pr: "commit, push, and create a PR", |
| 134 | }; |
| 135 | |
| 121 | 136 | function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { |
| 122 | 137 | if (icon === "commit") return <GitCommitIcon />; |
| 123 | 138 | if (icon === "push") return <CloudUploadIcon />; |
| ... | ... | @@ -145,6 +160,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 145 | 160 | const queryClient = useQueryClient(); |
| 146 | 161 | const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null); |
| 147 | 162 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 163 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = |
| 164 | useState<PendingDefaultBranchAction | null>(null); |
| 148 | 165 | |
| 149 | 166 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); |
| 150 | 167 | |
| ... | ... | @@ -167,12 +184,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 167 | 184 | const runImmediateGitActionMutation = useMutation( |
| 168 | 185 | gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), |
| 169 | 186 | ); |
| 187 | const createBranchAndCheckoutMutation = useMutation( |
| 188 | gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }), |
| 189 | ); |
| 170 | 190 | const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); |
| 171 | 191 | |
| 172 | 192 | const isRunStackedActionRunning = |
| 173 | 193 | useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; |
| 194 | const isCreateBranchAndCheckoutRunning = |
| 195 | useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0; |
| 174 | 196 | const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; |
| 175 | | const isGitActionRunning = isRunStackedActionRunning || isPullRunning; |
| 197 | const isGitActionRunning = |
| 198 | isRunStackedActionRunning || isCreateBranchAndCheckoutRunning || isPullRunning; |
| 176 | 199 | const isDefaultBranch = useMemo(() => { |
| 177 | 200 | const branchName = gitStatusForActions?.branch; |
| 178 | 201 | if (!branchName) return false; |
| ... | ... | @@ -192,23 +215,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 192 | 215 | ? (quickAction.hint ?? "This action is currently unavailable.") |
| 193 | 216 | : null; |
| 194 | 217 | |
| 195 | | const maybeConfirmPushToDefaultBranch = useCallback( |
| 196 | | async (action: GitStackedAction): Promise<boolean> => { |
| 197 | | const api = readNativeApi(); |
| 198 | | if (!api) return false; |
| 199 | | if ( |
| 200 | | !requiresDefaultBranchConfirmation(action, isDefaultBranch) || |
| 201 | | !gitStatusForActions?.branch |
| 202 | | ) { |
| 203 | | return true; |
| 204 | | } |
| 205 | | return api.dialogs.confirm( |
| 206 | | `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`, |
| 207 | | ); |
| 208 | | }, |
| 209 | | [gitStatusForActions?.branch, isDefaultBranch], |
| 210 | | ); |
| 211 | | |
| 212 | 218 | const openExistingPr = useCallback(async () => { |
| 213 | 219 | const api = readNativeApi(); |
| 214 | 220 | if (!api) { |
| ... | ... | @@ -237,29 +243,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 237 | 243 | }); |
| 238 | 244 | }); |
| 239 | 245 | }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); |
| 246 | |
| 240 | 247 | const runGitActionWithToast = useCallback( |
| 241 | 248 | async ({ |
| 242 | 249 | action, |
| 243 | 250 | commitMessage, |
| 244 | 251 | forcePushOnlyProgress = false, |
| 245 | 252 | onConfirmed, |
| 253 | skipDefaultBranchPrompt = false, |
| 254 | statusOverride, |
| 255 | isDefaultBranchOverride, |
| 246 | 256 | }: { |
| 247 | 257 | action: GitStackedAction; |
| 248 | 258 | commitMessage?: string; |
| 249 | 259 | forcePushOnlyProgress?: boolean; |
| 250 | 260 | onConfirmed?: () => void; |
| 261 | skipDefaultBranchPrompt?: boolean; |
| 262 | statusOverride?: GitStatusResult | null; |
| 263 | isDefaultBranchOverride?: boolean; |
| 251 | 264 | }) => { |
| 252 | | const confirmed = await maybeConfirmPushToDefaultBranch(action); |
| 253 | | if (!confirmed) return; |
| 265 | const actionStatus = statusOverride ?? gitStatusForActions; |
| 266 | const actionIsDefaultBranch = isDefaultBranchOverride ?? isDefaultBranch; |
| 267 | const actionBranch = actionStatus?.branch ?? null; |
| 268 | if ( |
| 269 | !skipDefaultBranchPrompt && |
| 270 | requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && |
| 271 | actionBranch |
| 272 | ) { |
| 273 | setPendingDefaultBranchAction({ |
| 274 | action, |
| 275 | ...(commitMessage ? { commitMessage } : {}), |
| 276 | forcePushOnlyProgress, |
| 277 | ...(onConfirmed ? { onConfirmed } : {}), |
| 278 | }); |
| 279 | return; |
| 280 | } |
| 254 | 281 | onConfirmed?.(); |
| 255 | 282 | |
| 256 | | const pushTarget = gitStatusForActions?.branch |
| 257 | | ? `origin/${gitStatusForActions.branch}` |
| 258 | | : undefined; |
| 283 | const pushTarget = actionBranch ? `origin/${actionBranch}` : undefined; |
| 259 | 284 | const progressStages = buildGitActionProgressStages({ |
| 260 | 285 | action, |
| 261 | 286 | hasCustomCommitMessage: !!commitMessage?.trim(), |
| 262 | | hasWorkingTreeChanges: !!gitStatusForActions?.hasWorkingTreeChanges, |
| 287 | hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, |
| 263 | 288 | forcePushOnly: forcePushOnlyProgress, |
| 264 | 289 | ...(pushTarget ? { pushTarget } : {}), |
| 265 | 290 | }); |
| ... | ... | @@ -295,14 +320,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 295 | 320 | stopProgressUpdates(); |
| 296 | 321 | const resultToast = summarizeGitResult(result); |
| 297 | 322 | |
| 298 | | const existingOpenPrUrl = |
| 299 | | gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined; |
| 323 | const existingOpenPrUrl = actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; |
| 300 | 324 | const prUrl = result.pr.url ?? existingOpenPrUrl; |
| 301 | 325 | const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; |
| 302 | 326 | const shouldOfferOpenPrCta = |
| 303 | | (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch; |
| 327 | (action === "commit_push" || action === "commit_push_pr") && |
| 328 | !!prUrl && |
| 329 | !actionIsDefaultBranch; |
| 304 | 330 | const shouldOfferCreatePrCta = |
| 305 | | action === "commit_push" && !prUrl && result.push.status === "pushed" && !isDefaultBranch; |
| 331 | action === "commit_push" && |
| 332 | !prUrl && |
| 333 | result.push.status === "pushed" && |
| 334 | !actionIsDefaultBranch; |
| 306 | 335 | const closeResultToast = () => { |
| 307 | 336 | toastManager.close(progressToastId); |
| 308 | 337 | }; |
| ... | ... | @@ -365,17 +394,67 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 365 | 394 | }, |
| 366 | 395 | |
| 367 | 396 | [ |
| 368 | | gitStatusForActions?.branch, |
| 369 | | gitStatusForActions?.hasWorkingTreeChanges, |
| 370 | | gitStatusForActions?.pr, |
| 371 | 397 | isDefaultBranch, |
| 372 | | maybeConfirmPushToDefaultBranch, |
| 373 | 398 | runImmediateGitActionMutation, |
| 374 | 399 | setActiveDialogAction, |
| 400 | setPendingDefaultBranchAction, |
| 375 | 401 | threadToastData, |
| 402 | gitStatusForActions, |
| 376 | 403 | ], |
| 377 | 404 | ); |
| 378 | 405 | |
| 406 | const continuePendingDefaultBranchAction = useCallback(() => { |
| 407 | if (!pendingDefaultBranchAction) return; |
| 408 | const pendingAction = pendingDefaultBranchAction; |
| 409 | setPendingDefaultBranchAction(null); |
| 410 | void runGitActionWithToast({ |
| 411 | ...pendingAction, |
| 412 | skipDefaultBranchPrompt: true, |
| 413 | }); |
| 414 | }, [pendingDefaultBranchAction, runGitActionWithToast]); |
| 415 | |
| 416 | const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { |
| 417 | if (!pendingDefaultBranchAction) return; |
| 418 | const pendingAction = pendingDefaultBranchAction; |
| 419 | const branchName = resolveAutoFeatureBranchName(branchList?.branches.map((branch) => branch.name) ?? []); |
| 420 | setPendingDefaultBranchAction(null); |
| 421 | |
| 422 | const checkoutPromise = createBranchAndCheckoutMutation.mutateAsync(branchName); |
| 423 | toastManager.promise(checkoutPromise, { |
| 424 | loading: { title: `Creating ${branchName}...`, data: threadToastData }, |
| 425 | success: () => ({ |
| 426 | title: `Checked out ${branchName}`, |
| 427 | data: threadToastData, |
| 428 | }), |
| 429 | error: (error) => ({ |
| 430 | title: "Failed to checkout feature branch", |
| 431 | description: error instanceof Error ? error.message : "An error occurred.", |
| 432 | data: threadToastData, |
| 433 | }), |
| 434 | }); |
| 435 | |
| 436 | void checkoutPromise |
| 437 | .then(() => { |
| 438 | const statusOverride = gitStatusForActions |
| 439 | ? { ...gitStatusForActions, branch: branchName, pr: null } |
| 440 | : null; |
| 441 | return runGitActionWithToast({ |
| 442 | ...pendingAction, |
| 443 | skipDefaultBranchPrompt: true, |
| 444 | statusOverride, |
| 445 | isDefaultBranchOverride: false, |
| 446 | }); |
| 447 | }) |
| 448 | .catch(() => undefined); |
| 449 | }, [ |
| 450 | branchList?.branches, |
| 451 | createBranchAndCheckoutMutation, |
| 452 | gitStatusForActions, |
| 453 | pendingDefaultBranchAction, |
| 454 | runGitActionWithToast, |
| 455 | threadToastData, |
| 456 | ]); |
| 457 | |
| 379 | 458 | const runQuickAction = useCallback(() => { |
| 380 | 459 | if (quickAction.kind === "open_pr") { |
| 381 | 460 | void openExistingPr(); |
| ... | ... | @@ -740,6 +819,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 740 | 819 | </DialogFooter> |
| 741 | 820 | </DialogPopup> |
| 742 | 821 | </Dialog> |
| 822 | |
| 823 | <Dialog |
| 824 | open={pendingDefaultBranchAction !== null} |
| 825 | onOpenChange={(open) => { |
| 826 | if (!open) { |
| 827 | setPendingDefaultBranchAction(null); |
| 828 | } |
| 829 | }} |
| 830 | > |
| 831 | <DialogPopup> |
| 832 | <DialogHeader> |
| 833 | <DialogTitle>Commit to default branch?</DialogTitle> |
| 834 | <DialogDescription> |
| 835 | This action will{" "} |
| 836 | {pendingDefaultBranchAction |
| 837 | ? DEFAULT_BRANCH_ACTION_DESCRIPTION[pendingDefaultBranchAction.action] |
| 838 | : "push changes"}{" "} |
| 839 | on "{gitStatusForActions?.branch ?? "default"}". You can continue on this branch or |
| 840 | create a feature branch and run the same action there. |
| 841 | </DialogDescription> |
| 842 | </DialogHeader> |
| 843 | <DialogFooter> |
| 844 | <Button variant="outline" size="sm" onClick={() => setPendingDefaultBranchAction(null)}> |
| 845 | Abort |
| 846 | </Button> |
| 847 | <Button variant="outline" size="sm" onClick={continuePendingDefaultBranchAction}> |
| 848 | Commit to {gitStatusForActions?.branch ?? "default"} |
| 849 | </Button> |
| 850 | <Button size="sm" onClick={checkoutFeatureBranchAndContinuePendingAction}> |
| 851 | Checkout feature branch & continue |
| 852 | </Button> |
| 853 | </DialogFooter> |
| 854 | </DialogPopup> |
| 855 | </Dialog> |
| 743 | 856 | </> |
| 744 | 857 | ); |
| 745 | 858 | } |