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