| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | type GitDialogAction, | 11 | type GitDialogAction, |
| 12 | type GitQuickAction, | 12 | type GitQuickAction, |
| 13 | requiresDefaultBranchConfirmation, | 13 | requiresDefaultBranchConfirmation, |
| | 14 | resolveAutoFeatureBranchName, |
| 14 | resolveQuickAction, | 15 | resolveQuickAction, |
| 15 | summarizeGitResult, | 16 | summarizeGitResult, |
| 16 | } from "./GitActionsControl.logic"; | 17 | } from "./GitActionsControl.logic"; |
| ... | @@ -32,6 +33,7 @@ import { Textarea } from "~/components/ui/textarea"; | ... | @@ -32,6 +33,7 @@ import { Textarea } from "~/components/ui/textarea"; |
| 32 | import { toastManager } from "~/components/ui/toast"; | 33 | import { toastManager } from "~/components/ui/toast"; |
| 33 | import { | 34 | import { |
| 34 | gitBranchesQueryOptions, | 35 | gitBranchesQueryOptions, |
| | 36 | gitCreateBranchAndCheckoutMutationOptions, |
| 35 | gitInitMutationOptions, | 37 | gitInitMutationOptions, |
| 36 | gitMutationKeys, | 38 | gitMutationKeys, |
| 37 | gitPullMutationOptions, | 39 | gitPullMutationOptions, |
| ... | @@ -47,6 +49,13 @@ interface GitActionsControlProps { | ... | @@ -47,6 +49,13 @@ interface GitActionsControlProps { |
| 47 | activeThreadId: ThreadId | null; | 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 | function getMenuActionDisabledReason( | 59 | function getMenuActionDisabledReason( |
| 51 | item: GitActionMenuItem, | 60 | item: GitActionMenuItem, |
| 52 | gitStatus: GitStatusResult | null, | 61 | gitStatus: GitStatusResult | null, |
| ... | @@ -118,6 +127,12 @@ const DIALOG_DESCRIPTION_BY_ACTION = { | ... | @@ -118,6 +127,12 @@ const DIALOG_DESCRIPTION_BY_ACTION = { |
| 118 | create_pr: "Create a pull request using generated title/body content.", | 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 | function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { | 136 | function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { |
| 122 | if (icon === "commit") return <GitCommitIcon />; | 137 | if (icon === "commit") return <GitCommitIcon />; |
| 123 | if (icon === "push") return <CloudUploadIcon />; | 138 | if (icon === "push") return <CloudUploadIcon />; |
| ... | @@ -145,6 +160,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -145,6 +160,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 145 | const queryClient = useQueryClient(); | 160 | const queryClient = useQueryClient(); |
| 146 | const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null); | 161 | const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null); |
| 147 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); | 162 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| | 163 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = |
| | 164 | useState<PendingDefaultBranchAction | null>(null); |
| 148 | | 165 | |
| 149 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); | 166 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); |
| 150 | | 167 | |
| ... | @@ -167,12 +184,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -167,12 +184,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 167 | const runImmediateGitActionMutation = useMutation( | 184 | const runImmediateGitActionMutation = useMutation( |
| 168 | gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), | 185 | gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), |
| 169 | ); | 186 | ); |
| | 187 | const createBranchAndCheckoutMutation = useMutation( |
| | 188 | gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }), |
| | 189 | ); |
| 170 | const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); | 190 | const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); |
| 171 | | 191 | |
| 172 | const isRunStackedActionRunning = | 192 | const isRunStackedActionRunning = |
| 173 | useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; | 193 | useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; |
| | 194 | const isCreateBranchAndCheckoutRunning = |
| | 195 | useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0; |
| 174 | const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; | 196 | const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; |
| 175 | const isGitActionRunning = isRunStackedActionRunning || isPullRunning; | 197 | const isGitActionRunning = |
| | 198 | isRunStackedActionRunning || isCreateBranchAndCheckoutRunning || isPullRunning; |
| 176 | const isDefaultBranch = useMemo(() => { | 199 | const isDefaultBranch = useMemo(() => { |
| 177 | const branchName = gitStatusForActions?.branch; | 200 | const branchName = gitStatusForActions?.branch; |
| 178 | if (!branchName) return false; | 201 | if (!branchName) return false; |
| ... | @@ -192,23 +215,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -192,23 +215,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 192 | ? (quickAction.hint ?? "This action is currently unavailable.") | 215 | ? (quickAction.hint ?? "This action is currently unavailable.") |
| 193 | : null; | 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 | const openExistingPr = useCallback(async () => { | 218 | const openExistingPr = useCallback(async () => { |
| 213 | const api = readNativeApi(); | 219 | const api = readNativeApi(); |
| 214 | if (!api) { | 220 | if (!api) { |
| ... | @@ -237,29 +243,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -237,29 +243,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 237 | }); | 243 | }); |
| 238 | }); | 244 | }); |
| 239 | }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); | 245 | }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); |
| | 246 | |
| 240 | const runGitActionWithToast = useCallback( | 247 | const runGitActionWithToast = useCallback( |
| 241 | async ({ | 248 | async ({ |
| 242 | action, | 249 | action, |
| 243 | commitMessage, | 250 | commitMessage, |
| 244 | forcePushOnlyProgress = false, | 251 | forcePushOnlyProgress = false, |
| 245 | onConfirmed, | 252 | onConfirmed, |
| | 253 | skipDefaultBranchPrompt = false, |
| | 254 | statusOverride, |
| | 255 | isDefaultBranchOverride, |
| 246 | }: { | 256 | }: { |
| 247 | action: GitStackedAction; | 257 | action: GitStackedAction; |
| 248 | commitMessage?: string; | 258 | commitMessage?: string; |
| 249 | forcePushOnlyProgress?: boolean; | 259 | forcePushOnlyProgress?: boolean; |
| 250 | onConfirmed?: () => void; | 260 | onConfirmed?: () => void; |
| | 261 | skipDefaultBranchPrompt?: boolean; |
| | 262 | statusOverride?: GitStatusResult | null; |
| | 263 | isDefaultBranchOverride?: boolean; |
| 251 | }) => { | 264 | }) => { |
| 252 | const confirmed = await maybeConfirmPushToDefaultBranch(action); | 265 | const actionStatus = statusOverride ?? gitStatusForActions; |
| 253 | if (!confirmed) return; | 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 | onConfirmed?.(); | 281 | onConfirmed?.(); |
| 255 | | 282 | |
| 256 | const pushTarget = gitStatusForActions?.branch | 283 | const pushTarget = actionBranch ? `origin/${actionBranch}` : undefined; |
| 257 | ? `origin/${gitStatusForActions.branch}` | | |
| 258 | : undefined; | | |
| 259 | const progressStages = buildGitActionProgressStages({ | 284 | const progressStages = buildGitActionProgressStages({ |
| 260 | action, | 285 | action, |
| 261 | hasCustomCommitMessage: !!commitMessage?.trim(), | 286 | hasCustomCommitMessage: !!commitMessage?.trim(), |
| 262 | hasWorkingTreeChanges: !!gitStatusForActions?.hasWorkingTreeChanges, | 287 | hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, |
| 263 | forcePushOnly: forcePushOnlyProgress, | 288 | forcePushOnly: forcePushOnlyProgress, |
| 264 | ...(pushTarget ? { pushTarget } : {}), | 289 | ...(pushTarget ? { pushTarget } : {}), |
| 265 | }); | 290 | }); |
| ... | @@ -295,14 +320,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -295,14 +320,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 295 | stopProgressUpdates(); | 320 | stopProgressUpdates(); |
| 296 | const resultToast = summarizeGitResult(result); | 321 | const resultToast = summarizeGitResult(result); |
| 297 | | 322 | |
| 298 | const existingOpenPrUrl = | 323 | const existingOpenPrUrl = actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; |
| 299 | gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined; | | |
| 300 | const prUrl = result.pr.url ?? existingOpenPrUrl; | 324 | const prUrl = result.pr.url ?? existingOpenPrUrl; |
| 301 | const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; | 325 | const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; |
| 302 | const shouldOfferOpenPrCta = | 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 | const shouldOfferCreatePrCta = | 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 | const closeResultToast = () => { | 335 | const closeResultToast = () => { |
| 307 | toastManager.close(progressToastId); | 336 | toastManager.close(progressToastId); |
| 308 | }; | 337 | }; |
| ... | @@ -365,17 +394,67 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -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 | isDefaultBranch, | 397 | isDefaultBranch, |
| 372 | maybeConfirmPushToDefaultBranch, | | |
| 373 | runImmediateGitActionMutation, | 398 | runImmediateGitActionMutation, |
| 374 | setActiveDialogAction, | 399 | setActiveDialogAction, |
| | 400 | setPendingDefaultBranchAction, |
| 375 | threadToastData, | 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 | const runQuickAction = useCallback(() => { | 458 | const runQuickAction = useCallback(() => { |
| 380 | if (quickAction.kind === "open_pr") { | 459 | if (quickAction.kind === "open_pr") { |
| 381 | void openExistingPr(); | 460 | void openExistingPr(); |
| ... | @@ -740,6 +819,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -740,6 +819,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 740 | </DialogFooter> | 819 | </DialogFooter> |
| 741 | </DialogPopup> | 820 | </DialogPopup> |
| 742 | </Dialog> | 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 | } |