| ... | ... | @@ -64,6 +64,22 @@ function GitActionIcon(props: { icon: GitActionIconName; disabled: boolean }) { |
| 64 | 64 | return <GitHubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />; |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | function GitProgressStatusIcon(props: { status: GitProgressStepStatus }) { |
| 68 | if (props.status === "running") { |
| 69 | return <Loader2Icon className="h-4 w-4 animate-spin text-foreground" />; |
| 70 | } |
| 71 | if (props.status === "completed") { |
| 72 | return <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />; |
| 73 | } |
| 74 | if (props.status === "skipped") { |
| 75 | return <MinusIcon className="h-4 w-4 text-muted-foreground/70" />; |
| 76 | } |
| 77 | if (props.status === "failed") { |
| 78 | return <XIcon className="h-4 w-4 text-rose-500 dark:text-rose-300" />; |
| 79 | } |
| 80 | return <CircleIcon className="h-4 w-4 text-muted-foreground/60" />; |
| 81 | } |
| 82 | |
| 67 | 83 | function gitActionModalTitle(): string { |
| 68 | 84 | return "Commit your changes"; |
| 69 | 85 | } |
| ... | ... | @@ -249,6 +265,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 249 | 265 | const [gitModalProgress, setGitModalProgress] = useState<GitProgressStep[]>([]); |
| 250 | 266 | const [gitModalError, setGitModalError] = useState<string | null>(null); |
| 251 | 267 | const [gitModalResult, setGitModalResult] = useState<GitRunStackedActionResult | null>(null); |
| 268 | const [isGitProgressToastVisible, setIsGitProgressToastVisible] = useState(false); |
| 252 | 269 | const gitMenuRef = useRef<HTMLDivElement>(null); |
| 253 | 270 | |
| 254 | 271 | const { data: gitStatus = null, error: gitStatusError } = useQuery( |
| ... | ... | @@ -291,10 +308,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 291 | 308 | [gitModalActionOptions, gitModalSelectedAction], |
| 292 | 309 | ); |
| 293 | 310 | const visibleGitActionError = gitActionError ?? gitStatusError?.message; |
| 294 | | const gitModalOpenPrUrl = gitModalResult?.pr.url ?? gitStatus?.openPr?.url ?? null; |
| 295 | | const gitModalHasProgress = gitModalProgress.length > 0; |
| 296 | | const gitModalSelectionMode = !gitModalHasProgress && gitModalResult === null; |
| 297 | | const gitModalSteps = gitModalProgress; |
| 311 | const gitProgressToastOpenPrUrl = gitModalResult?.pr.url ?? gitStatus?.openPr?.url ?? null; |
| 312 | const showGitProgressToast = |
| 313 | isGitProgressToastVisible && |
| 314 | (isGitModalActionRunning || |
| 315 | gitModalProgress.length > 0 || |
| 316 | gitModalError !== null || |
| 317 | gitModalResult !== null); |
| 298 | 318 | |
| 299 | 319 | const refreshGitStatus = useCallback(async () => { |
| 300 | 320 | if (!api || !gitCwd) return; |
| ... | ... | @@ -307,6 +327,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 307 | 327 | setIsGitModalOpen(true); |
| 308 | 328 | setGitModalSelectedAction("commit"); |
| 309 | 329 | setGitModalCommitMessage(""); |
| 330 | setIsGitProgressToastVisible(false); |
| 310 | 331 | setGitModalProgress([]); |
| 311 | 332 | setGitModalError(null); |
| 312 | 333 | setGitModalResult(null); |
| ... | ... | @@ -318,9 +339,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 318 | 339 | setIsGitModalOpen(false); |
| 319 | 340 | setGitModalSelectedAction("commit"); |
| 320 | 341 | setGitModalCommitMessage(""); |
| 321 | | setGitModalProgress([]); |
| 322 | | setGitModalError(null); |
| 323 | | setGitModalResult(null); |
| 324 | 342 | }, [isGitActionRunning]); |
| 325 | 343 | |
| 326 | 344 | const runGitActionImmediately = useCallback( |
| ... | ... | @@ -349,28 +367,39 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 349 | 367 | }); |
| 350 | 368 | }, [api, gitStatus?.openPr?.url]); |
| 351 | 369 | |
| 352 | | const openPrFromModal = useCallback(() => { |
| 370 | const openPrFromToast = useCallback(() => { |
| 353 | 371 | if (!api) { |
| 354 | 372 | setGitModalError("Link opening is unavailable."); |
| 355 | 373 | return; |
| 356 | 374 | } |
| 357 | 375 | |
| 358 | | const prUrl = gitModalOpenPrUrl; |
| 376 | const prUrl = gitProgressToastOpenPrUrl; |
| 359 | 377 | if (!prUrl) return; |
| 360 | 378 | |
| 361 | 379 | void api.shell.openExternal(prUrl).catch((error) => { |
| 362 | 380 | setGitModalError(error instanceof Error ? error.message : "Unable to open PR link."); |
| 363 | 381 | }); |
| 364 | | }, [api, gitModalOpenPrUrl]); |
| 382 | }, [api, gitProgressToastOpenPrUrl]); |
| 383 | |
| 384 | const dismissGitProgressToast = useCallback(() => { |
| 385 | if (isGitModalActionRunning) return; |
| 386 | setIsGitProgressToastVisible(false); |
| 387 | setGitModalProgress([]); |
| 388 | setGitModalError(null); |
| 389 | setGitModalResult(null); |
| 390 | }, [isGitModalActionRunning]); |
| 365 | 391 | |
| 366 | 392 | const runGitAction = useCallback(async () => { |
| 367 | | if (!api || !gitCwd || !isGitModalOpen) return; |
| 393 | if (!api || !gitCwd) return; |
| 368 | 394 | if (!selectedGitModalActionOption || selectedGitModalActionOption.disabled) return; |
| 369 | 395 | const actionCwd = gitCwd; |
| 370 | 396 | const action = selectedGitModalActionOption.action; |
| 371 | 397 | const commitMessage = gitModalCommitMessage.trim(); |
| 372 | 398 | const includeGeneratedCommitMessage = commitMessage.length === 0; |
| 373 | 399 | |
| 400 | setIsGitMenuOpen(false); |
| 401 | setIsGitModalOpen(false); |
| 402 | setIsGitProgressToastVisible(true); |
| 374 | 403 | setIsGitModalActionRunning(true); |
| 375 | 404 | setGitModalError(null); |
| 376 | 405 | setGitActionError(null); |
| ... | ... | @@ -488,7 +517,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 488 | 517 | api, |
| 489 | 518 | gitCwd, |
| 490 | 519 | gitModalCommitMessage, |
| 491 | | isGitModalOpen, |
| 492 | 520 | refreshGitStatus, |
| 493 | 521 | selectedGitModalActionOption, |
| 494 | 522 | ]); |
| ... | ... | @@ -497,6 +525,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 497 | 525 | setGitActionError(null); |
| 498 | 526 | setGitModalError(null); |
| 499 | 527 | setIsGitModalOpen(false); |
| 528 | setIsGitProgressToastVisible(false); |
| 500 | 529 | setGitModalSelectedAction("commit"); |
| 501 | 530 | setGitModalCommitMessage(""); |
| 502 | 531 | setGitModalProgress([]); |
| ... | ... | @@ -690,92 +719,57 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 690 | 719 | placeholder="Leave blank to autogenerate a commit message" |
| 691 | 720 | value={gitModalCommitMessage} |
| 692 | 721 | onChange={(event) => setGitModalCommitMessage(event.target.value)} |
| 693 | | disabled={!gitModalSelectionMode || isGitActionRunning} |
| 722 | disabled={isGitActionRunning} |
| 694 | 723 | /> |
| 695 | 724 | </div> |
| 696 | 725 | <div className="mt-6"> |
| 697 | 726 | <p className="text-sm font-medium text-foreground">Next steps</p> |
| 698 | 727 | <div className="mt-2 overflow-hidden rounded-2xl border border-border"> |
| 699 | | {gitModalSelectionMode && |
| 700 | | gitModalActionOptions.map((option, index) => { |
| 701 | | const borderClass = |
| 702 | | index < gitModalActionOptions.length - 1 ? "border-b border-border/70" : ""; |
| 703 | | const selected = option.action === gitModalSelectedAction && !option.disabled; |
| 704 | | const disabled = option.disabled || isGitActionRunning; |
| 705 | | |
| 706 | | return ( |
| 707 | | <button |
| 708 | | key={option.action} |
| 709 | | type="button" |
| 710 | | className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors duration-150 ${borderClass} ${ |
| 711 | | selected |
| 712 | | ? "bg-accent/55" |
| 713 | | : disabled |
| 714 | | ? "bg-card/25" |
| 715 | | : "bg-card/45 hover:bg-accent/35" |
| 716 | | } disabled:cursor-not-allowed`} |
| 717 | | disabled={disabled} |
| 718 | | onClick={() => { |
| 719 | | setGitModalSelectedAction(option.action); |
| 720 | | }} |
| 721 | | > |
| 722 | | <span className="mt-0.5"> |
| 723 | | <GitActionIcon icon={option.icon} disabled={option.disabled} /> |
| 724 | | </span> |
| 725 | | <div className="min-w-0 flex-1"> |
| 728 | {gitModalActionOptions.map((option, index) => { |
| 729 | const borderClass = |
| 730 | index < gitModalActionOptions.length - 1 ? "border-b border-border/70" : ""; |
| 731 | const selected = option.action === gitModalSelectedAction && !option.disabled; |
| 732 | const disabled = option.disabled || isGitActionRunning; |
| 733 | |
| 734 | return ( |
| 735 | <button |
| 736 | key={option.action} |
| 737 | type="button" |
| 738 | className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors duration-150 ${borderClass} ${ |
| 739 | selected |
| 740 | ? "bg-accent/55" |
| 741 | : disabled |
| 742 | ? "bg-card/25" |
| 743 | : "bg-card/45 hover:bg-accent/35" |
| 744 | } disabled:cursor-not-allowed`} |
| 745 | disabled={disabled} |
| 746 | onClick={() => { |
| 747 | setGitModalSelectedAction(option.action); |
| 748 | }} |
| 749 | > |
| 750 | <span className="mt-0.5"> |
| 751 | <GitActionIcon icon={option.icon} disabled={option.disabled} /> |
| 752 | </span> |
| 753 | <div className="min-w-0 flex-1"> |
| 754 | <p |
| 755 | className={`text-sm ${disabled ? "text-muted-foreground/60" : "text-foreground"}`} |
| 756 | > |
| 757 | {option.label} |
| 758 | </p> |
| 759 | {option.detail && ( |
| 726 | 760 | <p |
| 727 | | className={`text-sm ${disabled ? "text-muted-foreground/60" : "text-foreground"}`} |
| 761 | className={`mt-0.5 text-xs ${disabled ? "text-muted-foreground/55" : "text-muted-foreground/70"}`} |
| 728 | 762 | > |
| 729 | | {option.label} |
| 763 | {option.detail} |
| 730 | 764 | </p> |
| 731 | | {option.detail && ( |
| 732 | | <p |
| 733 | | className={`mt-0.5 text-xs ${disabled ? "text-muted-foreground/55" : "text-muted-foreground/70"}`} |
| 734 | | > |
| 735 | | {option.detail} |
| 736 | | </p> |
| 737 | | )} |
| 738 | | </div> |
| 739 | | {selected && !disabled ? ( |
| 740 | | <CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-foreground" /> |
| 741 | | ) : null} |
| 742 | | </button> |
| 743 | | ); |
| 744 | | })} |
| 745 | | {!gitModalSelectionMode && |
| 746 | | gitModalSteps.map((step, index) => { |
| 747 | | const borderClass = |
| 748 | | index < gitModalSteps.length - 1 ? "border-b border-border/70" : ""; |
| 749 | | |
| 750 | | return ( |
| 751 | | <div |
| 752 | | key={step.id} |
| 753 | | className={`flex items-start gap-3 bg-card/45 px-4 py-3 ${borderClass}`} |
| 754 | | > |
| 755 | | <span className="mt-0.5"> |
| 756 | | {step.status === "running" ? ( |
| 757 | | <Loader2Icon className="h-4 w-4 animate-spin text-foreground" /> |
| 758 | | ) : step.status === "completed" ? ( |
| 759 | | <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" /> |
| 760 | | ) : step.status === "skipped" ? ( |
| 761 | | <MinusIcon className="h-4 w-4 text-muted-foreground/70" /> |
| 762 | | ) : step.status === "failed" ? ( |
| 763 | | <XIcon className="h-4 w-4 text-rose-500 dark:text-rose-300" /> |
| 764 | | ) : ( |
| 765 | | <CircleIcon className="h-4 w-4 text-muted-foreground/60" /> |
| 766 | | )} |
| 767 | | </span> |
| 768 | | <div className="min-w-0"> |
| 769 | | <p className="text-sm text-foreground">{step.label}</p> |
| 770 | | {step.status === "failed" && step.detail && ( |
| 771 | | <p className="mt-0.5 text-xs text-rose-500 dark:text-rose-300"> |
| 772 | | {step.detail} |
| 773 | | </p> |
| 774 | | )} |
| 775 | | </div> |
| 765 | )} |
| 776 | 766 | </div> |
| 777 | | ); |
| 778 | | })} |
| 767 | {selected && !disabled ? ( |
| 768 | <CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-foreground" /> |
| 769 | ) : null} |
| 770 | </button> |
| 771 | ); |
| 772 | })} |
| 779 | 773 | </div> |
| 780 | 774 | </div> |
| 781 | 775 | {(gitModalError ?? visibleGitActionError) && ( |
| ... | ... | @@ -784,44 +778,116 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 784 | 778 | </div> |
| 785 | 779 | )} |
| 786 | 780 | <div className="mt-6 flex justify-end gap-2"> |
| 787 | | {gitModalResult && gitModalOpenPrUrl && ( |
| 788 | | <button |
| 789 | | type="button" |
| 790 | | className="rounded-xl border border-border px-4 py-2 text-sm text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60" |
| 791 | | onClick={openPrFromModal} |
| 792 | | disabled={isGitActionRunning} |
| 793 | | > |
| 794 | | Open PR |
| 795 | | </button> |
| 796 | | )} |
| 797 | 781 | <button |
| 798 | 782 | type="button" |
| 799 | | className={ |
| 800 | | gitModalResult |
| 801 | | ? "rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors duration-150 hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60" |
| 802 | | : "rounded-xl border border-border px-4 py-2 text-sm text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60" |
| 803 | | } |
| 783 | className="rounded-xl border border-border px-4 py-2 text-sm text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60" |
| 804 | 784 | onClick={closeGitActionModal} |
| 805 | 785 | disabled={isGitActionRunning} |
| 806 | 786 | > |
| 807 | | {gitModalResult ? "Done" : "Cancel"} |
| 787 | Cancel |
| 788 | </button> |
| 789 | <button |
| 790 | type="button" |
| 791 | className="rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors duration-150 hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60" |
| 792 | onClick={() => { |
| 793 | void runGitAction(); |
| 794 | }} |
| 795 | disabled={isGitActionRunning || !selectedGitModalActionOption} |
| 796 | > |
| 797 | {runActionLabel(gitModalSelectedAction)} |
| 798 | </button> |
| 799 | </div> |
| 800 | </div> |
| 801 | </div> |
| 802 | )} |
| 803 | |
| 804 | {showGitProgressToast && ( |
| 805 | <div |
| 806 | className="fixed bottom-4 right-4 z-[90] w-[min(420px,calc(100vw-2rem))]" |
| 807 | role="status" |
| 808 | aria-live="polite" |
| 809 | > |
| 810 | <div className="rounded-2xl border border-border bg-popover/95 p-4 shadow-2xl backdrop-blur"> |
| 811 | <div className="flex items-start justify-between gap-3"> |
| 812 | <div className="min-w-0"> |
| 813 | <p className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground/65"> |
| 814 | Git actions |
| 815 | </p> |
| 816 | <p className="mt-1 text-sm font-medium text-foreground"> |
| 817 | {isGitModalActionRunning |
| 818 | ? "Running action..." |
| 819 | : gitModalError |
| 820 | ? "Action failed" |
| 821 | : "Action complete"} |
| 822 | </p> |
| 823 | </div> |
| 824 | <button |
| 825 | type="button" |
| 826 | className="rounded-md p-1 text-muted-foreground/60 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50" |
| 827 | onClick={dismissGitProgressToast} |
| 828 | disabled={isGitModalActionRunning} |
| 829 | aria-label="Dismiss git action progress toast" |
| 830 | > |
| 831 | <XIcon className="h-4 w-4" /> |
| 808 | 832 | </button> |
| 809 | | {!gitModalResult && ( |
| 833 | </div> |
| 834 | {gitModalProgress.length > 0 && ( |
| 835 | <div className="mt-3 overflow-hidden rounded-xl border border-border/80"> |
| 836 | {gitModalProgress.map((step, index) => { |
| 837 | const borderClass = |
| 838 | index < gitModalProgress.length - 1 ? "border-b border-border/70" : ""; |
| 839 | |
| 840 | return ( |
| 841 | <div |
| 842 | key={step.id} |
| 843 | className={`flex items-start gap-3 bg-card/45 px-3.5 py-2.5 ${borderClass}`} |
| 844 | > |
| 845 | <span className="mt-0.5"> |
| 846 | <GitProgressStatusIcon status={step.status} /> |
| 847 | </span> |
| 848 | <div className="min-w-0"> |
| 849 | <p className="text-sm text-foreground">{step.label}</p> |
| 850 | {step.detail && ( |
| 851 | <p |
| 852 | className={`mt-0.5 text-xs ${ |
| 853 | step.status === "failed" |
| 854 | ? "text-rose-500 dark:text-rose-300" |
| 855 | : "text-muted-foreground/70" |
| 856 | }`} |
| 857 | > |
| 858 | {step.detail} |
| 859 | </p> |
| 860 | )} |
| 861 | </div> |
| 862 | </div> |
| 863 | ); |
| 864 | })} |
| 865 | </div> |
| 866 | )} |
| 867 | {gitModalError && ( |
| 868 | <div className="mt-3 rounded-lg border border-rose-300/50 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-200"> |
| 869 | {gitModalError} |
| 870 | </div> |
| 871 | )} |
| 872 | <div className="mt-3 flex justify-end gap-2"> |
| 873 | {!isGitModalActionRunning && gitModalResult && gitProgressToastOpenPrUrl && ( |
| 810 | 874 | <button |
| 811 | 875 | type="button" |
| 812 | | className="rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors duration-150 hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60" |
| 813 | | onClick={() => { |
| 814 | | void runGitAction(); |
| 815 | | }} |
| 816 | | disabled={isGitActionRunning || !selectedGitModalActionOption} |
| 876 | className="rounded-xl border border-border px-4 py-2 text-sm text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60" |
| 877 | onClick={openPrFromToast} |
| 878 | disabled={isGitModalActionRunning} |
| 817 | 879 | > |
| 818 | | {isGitActionRunning |
| 819 | | ? "Running..." |
| 820 | | : gitModalSelectionMode |
| 821 | | ? "Continue" |
| 822 | | : runActionLabel(gitModalSelectedAction)} |
| 880 | Open PR |
| 823 | 881 | </button> |
| 824 | 882 | )} |
| 883 | <button |
| 884 | type="button" |
| 885 | className="rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors duration-150 hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60" |
| 886 | onClick={dismissGitProgressToast} |
| 887 | disabled={isGitModalActionRunning} |
| 888 | > |
| 889 | Done |
| 890 | </button> |
| 825 | 891 | </div> |
| 826 | 892 | </div> |
| 827 | 893 | </div> |