| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | summarizeGitResult, |
| 17 | 17 | } from "./GitActionsControl.logic"; |
| 18 | 18 | import { Button } from "~/components/ui/button"; |
| 19 | import { Checkbox } from "~/components/ui/checkbox"; |
| 19 | 20 | import { |
| 20 | 21 | Dialog, |
| 21 | 22 | DialogDescription, |
| ... | ... | @@ -56,6 +57,7 @@ interface PendingDefaultBranchAction { |
| 56 | 57 | commitMessage?: string; |
| 57 | 58 | forcePushOnlyProgress: boolean; |
| 58 | 59 | onConfirmed?: () => void; |
| 60 | filePaths?: string[]; |
| 59 | 61 | } |
| 60 | 62 | |
| 61 | 63 | type GitActionToastId = ReturnType<typeof toastManager.add>; |
| ... | ... | @@ -159,6 +161,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 159 | 161 | const queryClient = useQueryClient(); |
| 160 | 162 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); |
| 161 | 163 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 164 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); |
| 165 | const [isEditingFiles, setIsEditingFiles] = useState(false); |
| 162 | 166 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = |
| 163 | 167 | useState<PendingDefaultBranchAction | null>(null); |
| 164 | 168 | |
| ... | ... | @@ -179,6 +183,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 179 | 183 | |
| 180 | 184 | const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; |
| 181 | 185 | |
| 186 | const allFiles = gitStatusForActions?.workingTree.files ?? []; |
| 187 | const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); |
| 188 | const allSelected = excludedFiles.size === 0; |
| 189 | const noneSelected = selectedFiles.length === 0; |
| 190 | |
| 182 | 191 | const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); |
| 183 | 192 | |
| 184 | 193 | const runImmediateGitActionMutation = useMutation( |
| ... | ... | @@ -257,6 +266,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 257 | 266 | featureBranch = false, |
| 258 | 267 | isDefaultBranchOverride, |
| 259 | 268 | progressToastId, |
| 269 | filePaths, |
| 260 | 270 | }: { |
| 261 | 271 | action: GitStackedAction; |
| 262 | 272 | commitMessage?: string; |
| ... | ... | @@ -267,6 +277,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 267 | 277 | featureBranch?: boolean; |
| 268 | 278 | isDefaultBranchOverride?: boolean; |
| 269 | 279 | progressToastId?: GitActionToastId; |
| 280 | filePaths?: string[]; |
| 270 | 281 | }) => { |
| 271 | 282 | const actionStatus = statusOverride ?? gitStatusForActions; |
| 272 | 283 | const actionBranch = actionStatus?.branch ?? null; |
| ... | ... | @@ -289,6 +300,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 289 | 300 | ...(commitMessage ? { commitMessage } : {}), |
| 290 | 301 | forcePushOnlyProgress, |
| 291 | 302 | ...(onConfirmed ? { onConfirmed } : {}), |
| 303 | ...(filePaths ? { filePaths } : {}), |
| 292 | 304 | }); |
| 293 | 305 | return; |
| 294 | 306 | } |
| ... | ... | @@ -338,6 +350,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 338 | 350 | action, |
| 339 | 351 | ...(commitMessage ? { commitMessage } : {}), |
| 340 | 352 | ...(featureBranch ? { featureBranch } : {}), |
| 353 | ...(filePaths ? { filePaths } : {}), |
| 341 | 354 | }); |
| 342 | 355 | |
| 343 | 356 | try { |
| ... | ... | @@ -439,7 +452,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 439 | 452 | |
| 440 | 453 | const continuePendingDefaultBranchAction = useCallback(() => { |
| 441 | 454 | if (!pendingDefaultBranchAction) return; |
| 442 | | const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = |
| 455 | const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = |
| 443 | 456 | pendingDefaultBranchAction; |
| 444 | 457 | setPendingDefaultBranchAction(null); |
| 445 | 458 | void runGitActionWithToast({ |
| ... | ... | @@ -447,6 +460,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 447 | 460 | ...(commitMessage ? { commitMessage } : {}), |
| 448 | 461 | forcePushOnlyProgress, |
| 449 | 462 | ...(onConfirmed ? { onConfirmed } : {}), |
| 463 | ...(filePaths ? { filePaths } : {}), |
| 450 | 464 | skipDefaultBranchPrompt: true, |
| 451 | 465 | }); |
| 452 | 466 | }, [pendingDefaultBranchAction, runGitActionWithToast]); |
| ... | ... | @@ -457,6 +471,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 457 | 471 | commitMessage?: string; |
| 458 | 472 | forcePushOnlyProgress?: boolean; |
| 459 | 473 | onConfirmed?: () => void; |
| 474 | filePaths?: string[]; |
| 460 | 475 | }) => { |
| 461 | 476 | void runGitActionWithToast({ |
| 462 | 477 | ...actionParams, |
| ... | ... | @@ -469,7 +484,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 469 | 484 | |
| 470 | 485 | const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { |
| 471 | 486 | if (!pendingDefaultBranchAction) return; |
| 472 | | const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = |
| 487 | const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = |
| 473 | 488 | pendingDefaultBranchAction; |
| 474 | 489 | setPendingDefaultBranchAction(null); |
| 475 | 490 | checkoutNewBranchAndRunAction({ |
| ... | ... | @@ -477,6 +492,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 477 | 492 | ...(commitMessage ? { commitMessage } : {}), |
| 478 | 493 | forcePushOnlyProgress, |
| 479 | 494 | ...(onConfirmed ? { onConfirmed } : {}), |
| 495 | ...(filePaths ? { filePaths } : {}), |
| 480 | 496 | }); |
| 481 | 497 | }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); |
| 482 | 498 | |
| ... | ... | @@ -486,12 +502,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 486 | 502 | |
| 487 | 503 | setIsCommitDialogOpen(false); |
| 488 | 504 | setDialogCommitMessage(""); |
| 505 | setExcludedFiles(new Set()); |
| 506 | setIsEditingFiles(false); |
| 489 | 507 | |
| 490 | 508 | checkoutNewBranchAndRunAction({ |
| 491 | 509 | action: "commit", |
| 492 | 510 | ...(commitMessage ? { commitMessage } : {}), |
| 511 | ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), |
| 493 | 512 | }); |
| 494 | | }, [isCommitDialogOpen, dialogCommitMessage, checkoutNewBranchAndRunAction]); |
| 513 | }, [ |
| 514 | allSelected, |
| 515 | isCommitDialogOpen, |
| 516 | dialogCommitMessage, |
| 517 | checkoutNewBranchAndRunAction, |
| 518 | selectedFiles, |
| 519 | ]); |
| 495 | 520 | |
| 496 | 521 | const runQuickAction = useCallback(() => { |
| 497 | 522 | if (quickAction.kind === "open_pr") { |
| ... | ... | @@ -548,6 +573,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 548 | 573 | void runGitActionWithToast({ action: "commit_push_pr" }); |
| 549 | 574 | return; |
| 550 | 575 | } |
| 576 | setExcludedFiles(new Set()); |
| 577 | setIsEditingFiles(false); |
| 551 | 578 | setIsCommitDialogOpen(true); |
| 552 | 579 | }, |
| 553 | 580 | [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen], |
| ... | ... | @@ -558,14 +585,19 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 558 | 585 | const commitMessage = dialogCommitMessage.trim(); |
| 559 | 586 | setIsCommitDialogOpen(false); |
| 560 | 587 | setDialogCommitMessage(""); |
| 588 | setExcludedFiles(new Set()); |
| 589 | setIsEditingFiles(false); |
| 561 | 590 | void runGitActionWithToast({ |
| 562 | 591 | action: "commit", |
| 563 | 592 | ...(commitMessage ? { commitMessage } : {}), |
| 593 | ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), |
| 564 | 594 | }); |
| 565 | 595 | }, [ |
| 596 | allSelected, |
| 566 | 597 | dialogCommitMessage, |
| 567 | 598 | isCommitDialogOpen, |
| 568 | 599 | runGitActionWithToast, |
| 600 | selectedFiles, |
| 569 | 601 | setDialogCommitMessage, |
| 570 | 602 | setIsCommitDialogOpen, |
| 571 | 603 | ]); |
| ... | ... | @@ -730,6 +762,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 730 | 762 | if (!open) { |
| 731 | 763 | setIsCommitDialogOpen(false); |
| 732 | 764 | setDialogCommitMessage(""); |
| 765 | setExcludedFiles(new Set()); |
| 766 | setIsEditingFiles(false); |
| 733 | 767 | } |
| 734 | 768 | }} |
| 735 | 769 | > |
| ... | ... | @@ -752,37 +786,99 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 752 | 786 | </span> |
| 753 | 787 | </div> |
| 754 | 788 | <div className="space-y-1"> |
| 755 | | <p className="text-muted-foreground">Files</p> |
| 756 | | {!gitStatusForActions || gitStatusForActions.workingTree.files.length === 0 ? ( |
| 789 | <div className="flex items-center justify-between"> |
| 790 | <div className="flex items-center gap-2"> |
| 791 | {isEditingFiles && allFiles.length > 0 && ( |
| 792 | <Checkbox |
| 793 | checked={allSelected} |
| 794 | indeterminate={!allSelected && !noneSelected} |
| 795 | onCheckedChange={() => { |
| 796 | setExcludedFiles( |
| 797 | allSelected ? new Set(allFiles.map((f) => f.path)) : new Set(), |
| 798 | ); |
| 799 | }} |
| 800 | /> |
| 801 | )} |
| 802 | <span className="text-muted-foreground">Files</span> |
| 803 | {!allSelected && !isEditingFiles && ( |
| 804 | <span className="text-muted-foreground"> |
| 805 | ({selectedFiles.length} of {allFiles.length}) |
| 806 | </span> |
| 807 | )} |
| 808 | </div> |
| 809 | {allFiles.length > 0 && ( |
| 810 | <Button |
| 811 | variant="ghost" |
| 812 | size="xs" |
| 813 | onClick={() => setIsEditingFiles((prev) => !prev)} |
| 814 | > |
| 815 | {isEditingFiles ? "Done" : "Edit"} |
| 816 | </Button> |
| 817 | )} |
| 818 | </div> |
| 819 | {!gitStatusForActions || allFiles.length === 0 ? ( |
| 757 | 820 | <p className="font-medium">none</p> |
| 758 | 821 | ) : ( |
| 759 | 822 | <div className="space-y-2"> |
| 760 | 823 | <ScrollArea className="h-44 rounded-md border border-input bg-background"> |
| 761 | 824 | <div className="space-y-1 p-1"> |
| 762 | | {gitStatusForActions.workingTree.files.map((file) => ( |
| 763 | | <button |
| 764 | | type="button" |
| 765 | | key={file.path} |
| 766 | | className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-1 font-mono text-left transition-colors hover:bg-accent/50" |
| 767 | | onClick={() => openChangedFileInEditor(file.path)} |
| 768 | | > |
| 769 | | <span className="truncate">{file.path}</span> |
| 770 | | <span className="shrink-0"> |
| 771 | | <span className="text-success">+{file.insertions}</span> |
| 772 | | <span className="text-muted-foreground"> / </span> |
| 773 | | <span className="text-destructive">-{file.deletions}</span> |
| 774 | | </span> |
| 775 | | </button> |
| 776 | | ))} |
| 825 | {allFiles.map((file) => { |
| 826 | const isExcluded = excludedFiles.has(file.path); |
| 827 | return ( |
| 828 | <div |
| 829 | key={file.path} |
| 830 | className="flex w-full items-center gap-2 rounded-md px-2 py-1 font-mono text-xs transition-colors hover:bg-accent/50" |
| 831 | > |
| 832 | {isEditingFiles && ( |
| 833 | <Checkbox |
| 834 | checked={!excludedFiles.has(file.path)} |
| 835 | onCheckedChange={() => { |
| 836 | setExcludedFiles((prev) => { |
| 837 | const next = new Set(prev); |
| 838 | if (next.has(file.path)) { |
| 839 | next.delete(file.path); |
| 840 | } else { |
| 841 | next.add(file.path); |
| 842 | } |
| 843 | return next; |
| 844 | }); |
| 845 | }} |
| 846 | /> |
| 847 | )} |
| 848 | <button |
| 849 | type="button" |
| 850 | className="flex flex-1 items-center justify-between gap-3 text-left truncate" |
| 851 | onClick={() => openChangedFileInEditor(file.path)} |
| 852 | > |
| 853 | <span |
| 854 | className={`truncate${isExcluded ? " text-muted-foreground" : ""}`} |
| 855 | > |
| 856 | {file.path} |
| 857 | </span> |
| 858 | <span className="shrink-0"> |
| 859 | {isExcluded ? ( |
| 860 | <span className="text-muted-foreground">Excluded</span> |
| 861 | ) : ( |
| 862 | <> |
| 863 | <span className="text-success">+{file.insertions}</span> |
| 864 | <span className="text-muted-foreground"> / </span> |
| 865 | <span className="text-destructive">-{file.deletions}</span> |
| 866 | </> |
| 867 | )} |
| 868 | </span> |
| 869 | </button> |
| 870 | </div> |
| 871 | ); |
| 872 | })} |
| 777 | 873 | </div> |
| 778 | 874 | </ScrollArea> |
| 779 | 875 | <div className="flex justify-end font-mono"> |
| 780 | 876 | <span className="text-success"> |
| 781 | | +{gitStatusForActions.workingTree.insertions} |
| 877 | +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} |
| 782 | 878 | </span> |
| 783 | 879 | <span className="text-muted-foreground"> / </span> |
| 784 | 880 | <span className="text-destructive"> |
| 785 | | -{gitStatusForActions.workingTree.deletions} |
| 881 | -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)} |
| 786 | 882 | </span> |
| 787 | 883 | </div> |
| 788 | 884 | </div> |
| ... | ... | @@ -806,14 +902,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 806 | 902 | onClick={() => { |
| 807 | 903 | setIsCommitDialogOpen(false); |
| 808 | 904 | setDialogCommitMessage(""); |
| 905 | setExcludedFiles(new Set()); |
| 906 | setIsEditingFiles(false); |
| 809 | 907 | }} |
| 810 | 908 | > |
| 811 | 909 | Cancel |
| 812 | 910 | </Button> |
| 813 | | <Button variant="outline" size="sm" onClick={runDialogActionOnNewBranch}> |
| 911 | <Button |
| 912 | variant="outline" |
| 913 | size="sm" |
| 914 | disabled={noneSelected} |
| 915 | onClick={runDialogActionOnNewBranch} |
| 916 | > |
| 814 | 917 | Commit on new branch |
| 815 | 918 | </Button> |
| 816 | | <Button size="sm" onClick={runDialogAction}> |
| 919 | <Button size="sm" disabled={noneSelected} onClick={runDialogAction}> |
| 817 | 920 | Commit |
| 818 | 921 | </Button> |
| 819 | 922 | </DialogFooter> |