| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts"; |
| 2 | 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | | import { useCallback, useMemo, useState } from "react"; |
| 3 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 4 | 4 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| 5 | 5 | import { GitHubIcon } from "./Icons"; |
| 6 | 6 | import { |
| ... | ... | @@ -154,6 +154,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 154 | 154 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd)); |
| 155 | 155 | // Default to true while loading so we don't flash init controls. |
| 156 | 156 | const isRepo = branchList?.isRepo ?? true; |
| 157 | const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; |
| 158 | const isGitStatusOutOfSync = |
| 159 | !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; |
| 160 | |
| 161 | useEffect(() => { |
| 162 | if (!isGitStatusOutOfSync) return; |
| 163 | void invalidateGitQueries(queryClient); |
| 164 | }, [isGitStatusOutOfSync, queryClient]); |
| 165 | |
| 166 | const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; |
| 157 | 167 | |
| 158 | 168 | const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient })); |
| 159 | 169 | |
| ... | ... | @@ -164,19 +174,19 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 164 | 174 | |
| 165 | 175 | const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; |
| 166 | 176 | const isDefaultBranch = useMemo(() => { |
| 167 | | const currentBranch = gitStatus?.branch; |
| 168 | | if (!currentBranch) return false; |
| 169 | | const current = branchList?.branches.find((branch) => branch.name === currentBranch); |
| 170 | | return current?.isDefault ?? (currentBranch === "main" || currentBranch === "master"); |
| 171 | | }, [branchList?.branches, gitStatus?.branch]); |
| 177 | const branchName = gitStatusForActions?.branch; |
| 178 | if (!branchName) return false; |
| 179 | const current = branchList?.branches.find((branch) => branch.name === branchName); |
| 180 | return current?.isDefault ?? (branchName === "main" || branchName === "master"); |
| 181 | }, [branchList?.branches, gitStatusForActions?.branch]); |
| 172 | 182 | |
| 173 | 183 | const gitActionMenuItems = useMemo( |
| 174 | | () => buildMenuItems(gitStatus, isGitActionRunning), |
| 175 | | [gitStatus, isGitActionRunning], |
| 184 | () => buildMenuItems(gitStatusForActions, isGitActionRunning), |
| 185 | [gitStatusForActions, isGitActionRunning], |
| 176 | 186 | ); |
| 177 | 187 | const quickAction = useMemo( |
| 178 | | () => resolveQuickAction(gitStatus, isGitActionRunning, isDefaultBranch), |
| 179 | | [gitStatus, isDefaultBranch, isGitActionRunning], |
| 188 | () => resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch), |
| 189 | [gitStatusForActions, isDefaultBranch, isGitActionRunning], |
| 180 | 190 | ); |
| 181 | 191 | const quickActionDisabledReason = quickAction.disabled |
| 182 | 192 | ? (quickAction.hint ?? "This action is currently unavailable.") |
| ... | ... | @@ -185,14 +195,17 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 185 | 195 | const maybeConfirmPushToDefaultBranch = useCallback( |
| 186 | 196 | async (action: GitStackedAction): Promise<boolean> => { |
| 187 | 197 | if (!api) return false; |
| 188 | | if (!requiresDefaultBranchConfirmation(action, isDefaultBranch) || !gitStatus?.branch) { |
| 198 | if ( |
| 199 | !requiresDefaultBranchConfirmation(action, isDefaultBranch) || |
| 200 | !gitStatusForActions?.branch |
| 201 | ) { |
| 189 | 202 | return true; |
| 190 | 203 | } |
| 191 | 204 | return api.dialogs.confirm( |
| 192 | | `You're about to push to the default branch "${gitStatus.branch}". Continue?`, |
| 205 | `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`, |
| 193 | 206 | ); |
| 194 | 207 | }, |
| 195 | | [api, gitStatus?.branch, isDefaultBranch], |
| 208 | [api, gitStatusForActions?.branch, isDefaultBranch], |
| 196 | 209 | ); |
| 197 | 210 | |
| 198 | 211 | const openExistingPr = useCallback(async () => { |
| ... | ... | @@ -204,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 204 | 217 | }); |
| 205 | 218 | return; |
| 206 | 219 | } |
| 207 | | const prUrl = gitStatus?.openPr?.url ?? null; |
| 220 | const prUrl = gitStatusForActions?.openPr?.url ?? null; |
| 208 | 221 | if (!prUrl) { |
| 209 | 222 | toastManager.add({ |
| 210 | 223 | type: "error", |
| ... | ... | @@ -224,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 224 | 237 | }), |
| 225 | 238 | }); |
| 226 | 239 | void promise.catch(() => undefined); |
| 227 | | }, [api, gitStatus?.openPr?.url, threadToastData]); |
| 240 | }, [api, gitStatusForActions?.openPr?.url, threadToastData]); |
| 228 | 241 | |
| 229 | 242 | const runGitActionWithToast = useCallback( |
| 230 | 243 | async ({ |
| ... | ... | @@ -242,11 +255,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 242 | 255 | if (!confirmed) return; |
| 243 | 256 | onConfirmed?.(); |
| 244 | 257 | |
| 245 | | const pushTarget = gitStatus?.branch ? `origin/${gitStatus.branch}` : undefined; |
| 258 | const pushTarget = gitStatusForActions?.branch |
| 259 | ? `origin/${gitStatusForActions.branch}` |
| 260 | : undefined; |
| 246 | 261 | const progressStages = buildGitActionProgressStages({ |
| 247 | 262 | action, |
| 248 | 263 | hasCustomCommitMessage: !!commitMessage?.trim(), |
| 249 | | hasWorkingTreeChanges: !!gitStatus?.hasWorkingTreeChanges, |
| 264 | hasWorkingTreeChanges: !!gitStatusForActions?.hasWorkingTreeChanges, |
| 250 | 265 | forcePushOnly: forcePushOnlyProgress, |
| 251 | 266 | ...(pushTarget ? { pushTarget } : {}), |
| 252 | 267 | }); |
| ... | ... | @@ -282,7 +297,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 282 | 297 | stopProgressUpdates(); |
| 283 | 298 | const resultToast = summarizeGitResult(result); |
| 284 | 299 | |
| 285 | | const prUrl = result.pr.url ?? gitStatus?.openPr?.url; |
| 300 | const prUrl = result.pr.url ?? gitStatusForActions?.openPr?.url; |
| 286 | 301 | const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; |
| 287 | 302 | const shouldOfferOpenPrCta = |
| 288 | 303 | (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch; |
| ... | ... | @@ -345,9 +360,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 345 | 360 | }, |
| 346 | 361 | [ |
| 347 | 362 | api, |
| 348 | | gitStatus?.branch, |
| 349 | | gitStatus?.hasWorkingTreeChanges, |
| 350 | | gitStatus?.openPr?.url, |
| 363 | gitStatusForActions?.branch, |
| 364 | gitStatusForActions?.hasWorkingTreeChanges, |
| 365 | gitStatusForActions?.openPr?.url, |
| 351 | 366 | isDefaultBranch, |
| 352 | 367 | maybeConfirmPushToDefaultBranch, |
| 353 | 368 | runImmediateGitActionMutation, |
| ... | ... | @@ -517,7 +532,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 517 | 532 | {gitActionMenuItems.map((item) => { |
| 518 | 533 | const disabledReason = getMenuActionDisabledReason( |
| 519 | 534 | item, |
| 520 | | gitStatus, |
| 535 | gitStatusForActions, |
| 521 | 536 | isGitActionRunning, |
| 522 | 537 | ); |
| 523 | 538 | if (item.disabled && disabledReason) { |
| ... | ... | @@ -553,19 +568,25 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 553 | 568 | </MenuItem> |
| 554 | 569 | ); |
| 555 | 570 | })} |
| 556 | | {gitStatus?.branch === null && ( |
| 571 | {gitStatusForActions?.branch === null && ( |
| 557 | 572 | <p className="px-2 py-1.5 text-xs text-warning"> |
| 558 | 573 | Detached HEAD: create and checkout a branch to enable push and PR actions. |
| 559 | 574 | </p> |
| 560 | 575 | )} |
| 561 | | {gitStatus && |
| 562 | | gitStatus.branch !== null && |
| 563 | | !gitStatus.hasWorkingTreeChanges && |
| 564 | | gitStatus.behindCount > 0 && ( |
| 576 | {gitStatusForActions && |
| 577 | gitStatusForActions.branch !== null && |
| 578 | !gitStatusForActions.hasWorkingTreeChanges && |
| 579 | gitStatusForActions.behindCount > 0 && |
| 580 | gitStatusForActions.aheadCount === 0 && ( |
| 565 | 581 | <p className="px-2 py-1.5 text-xs text-warning"> |
| 566 | 582 | Behind upstream. Pull/rebase first. |
| 567 | 583 | </p> |
| 568 | 584 | )} |
| 585 | {isGitStatusOutOfSync && ( |
| 586 | <p className="px-2 py-1.5 text-xs text-muted-foreground"> |
| 587 | Refreshing git status... |
| 588 | </p> |
| 589 | )} |
| 569 | 590 | {gitStatusError && ( |
| 570 | 591 | <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p> |
| 571 | 592 | )} |
| ... | ... | @@ -594,37 +615,42 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 594 | 615 | <div className="space-y-3 rounded-lg border border-input bg-muted/40 p-3 text-xs"> |
| 595 | 616 | <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1"> |
| 596 | 617 | <span className="text-muted-foreground">Branch</span> |
| 597 | | <span className="font-medium">{gitStatus?.branch ?? "(detached HEAD)"}</span> |
| 618 | <span className="font-medium"> |
| 619 | {gitStatusForActions?.branch ?? "(detached HEAD)"} |
| 620 | </span> |
| 598 | 621 | {activeDialogAction !== "commit" && ( |
| 599 | 622 | <> |
| 600 | 623 | <span className="text-muted-foreground">Upstream</span> |
| 601 | 624 | <span className="font-medium"> |
| 602 | | {!gitStatus || !gitStatus.hasUpstream |
| 625 | {!gitStatusForActions || !gitStatusForActions.hasUpstream |
| 603 | 626 | ? "No upstream configured" |
| 604 | | : gitStatus.aheadCount === 0 && gitStatus.behindCount === 0 |
| 627 | : gitStatusForActions.aheadCount === 0 && |
| 628 | gitStatusForActions.behindCount === 0 |
| 605 | 629 | ? "Up to date" |
| 606 | | : gitStatus.aheadCount > 0 && gitStatus.behindCount > 0 |
| 607 | | ? `Diverged (+${gitStatus.aheadCount} / -${gitStatus.behindCount})` |
| 608 | | : gitStatus.aheadCount > 0 |
| 609 | | ? `Ahead by ${gitStatus.aheadCount}` |
| 610 | | : `Behind by ${gitStatus.behindCount}`} |
| 630 | : gitStatusForActions.aheadCount > 0 && gitStatusForActions.behindCount > 0 |
| 631 | ? `Diverged (+${gitStatusForActions.aheadCount} / -${gitStatusForActions.behindCount})` |
| 632 | : gitStatusForActions.aheadCount > 0 |
| 633 | ? `Ahead by ${gitStatusForActions.aheadCount}` |
| 634 | : `Behind by ${gitStatusForActions.behindCount}`} |
| 611 | 635 | </span> |
| 612 | 636 | <span className="text-muted-foreground">Working tree</span> |
| 613 | 637 | <span className="font-medium"> |
| 614 | | {!gitStatus || !gitStatus.hasWorkingTreeChanges |
| 638 | {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges |
| 615 | 639 | ? "Clean" |
| 616 | | : `${gitStatus.workingTree.files.length} file(s)`} |
| 640 | : `${gitStatusForActions.workingTree.files.length} file(s)`} |
| 617 | 641 | </span> |
| 618 | 642 | <span className="text-muted-foreground">Diff</span> |
| 619 | 643 | <span className="font-mono"> |
| 620 | | {!gitStatus || !gitStatus.hasWorkingTreeChanges ? ( |
| 644 | {!gitStatusForActions || !gitStatusForActions.hasWorkingTreeChanges ? ( |
| 621 | 645 | "none" |
| 622 | 646 | ) : ( |
| 623 | 647 | <> |
| 624 | | <span className="text-success">+{gitStatus.workingTree.insertions}</span> |
| 648 | <span className="text-success"> |
| 649 | +{gitStatusForActions.workingTree.insertions} |
| 650 | </span> |
| 625 | 651 | <span className="text-muted-foreground"> / </span> |
| 626 | 652 | <span className="text-destructive"> |
| 627 | | -{gitStatus.workingTree.deletions} |
| 653 | -{gitStatusForActions.workingTree.deletions} |
| 628 | 654 | </span> |
| 629 | 655 | </> |
| 630 | 656 | )} |
| ... | ... | @@ -634,13 +660,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 634 | 660 | </div> |
| 635 | 661 | <div className="space-y-1"> |
| 636 | 662 | <p className="text-muted-foreground">Files</p> |
| 637 | | {!gitStatus || gitStatus.workingTree.files.length === 0 ? ( |
| 663 | {!gitStatusForActions || gitStatusForActions.workingTree.files.length === 0 ? ( |
| 638 | 664 | <p className="font-medium">none</p> |
| 639 | 665 | ) : ( |
| 640 | 666 | <div className="space-y-2"> |
| 641 | 667 | <ScrollArea className="h-44 rounded-md border border-input bg-background"> |
| 642 | 668 | <div className="space-y-1 p-1"> |
| 643 | | {gitStatus.workingTree.files.map((file) => ( |
| 669 | {gitStatusForActions.workingTree.files.map((file) => ( |
| 644 | 670 | <button |
| 645 | 671 | type="button" |
| 646 | 672 | key={file.path} |
| ... | ... | @@ -658,9 +684,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 658 | 684 | </div> |
| 659 | 685 | </ScrollArea> |
| 660 | 686 | <div className="flex justify-end font-mono"> |
| 661 | | <span className="text-success">+{gitStatus.workingTree.insertions}</span> |
| 687 | <span className="text-success"> |
| 688 | +{gitStatusForActions.workingTree.insertions} |
| 689 | </span> |
| 662 | 690 | <span className="text-muted-foreground"> / </span> |
| 663 | | <span className="text-destructive">-{gitStatus.workingTree.deletions}</span> |
| 691 | <span className="text-destructive"> |
| 692 | -{gitStatusForActions.workingTree.deletions} |
| 693 | </span> |
| 664 | 694 | </div> |
| 665 | 695 | </div> |
| 666 | 696 | )} |