| @@ -11,6 +11,7 @@ import { · 10 unmodified lines |
| 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"; · 15 unmodified lines |
| 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 { · 9 unmodified lines |
| 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 = { · 65 unmodified lines |
| 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 · 21 unmodified lines |
| 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 · 16 unmodified lines |
| 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 · 13 unmodified lines |
| 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 · 22 unmodified lines |
| 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 · 29 unmodified lines |
| 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 · 56 unmodified lines |
| 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 · 358 unmodified lines |
| 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 | } |