diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 32c660546c3969e6c5df35844227c5708b4614e2..cbd0758aae428de0e2cdb8945bd2680ccfa27a8b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -4,36 +4,44 @@ import { type GitStatusResult, type NativeApi, } from "@t3tools/contracts"; +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckIcon, CircleIcon, CloudUploadIcon, GitCommitIcon, - GithubIcon, Loader2Icon, MinusIcon, XIcon, } from "lucide-react"; +import { GitHubIcon } from "./Icons"; interface GitActionsControlProps { api: NativeApi | undefined; gitCwd: string | null; } +type GitActionIconName = "commit" | "push" | "pr"; + interface GitActionMenuItem { - id: GitStackedAction; + id: "commit" | "push" | "pr"; label: string; disabled: boolean; - icon: "commit" | "push" | "pr"; + icon: GitActionIconName; + kind: "open_modal" | "run_action" | "open_pr"; + action?: GitStackedAction; } -type GitProgressStepStatus = - | "pending" - | "running" - | "completed" - | "skipped" - | "failed"; +interface GitModalActionOption { + action: GitStackedAction; + label: string; + disabled: boolean; + icon: GitActionIconName; + detail?: string; +} + +type GitProgressStepStatus = "pending" | "running" | "completed" | "skipped" | "failed"; interface GitProgressStep { id: "generate" | "commit" | "push" | "pr"; @@ -42,7 +50,7 @@ interface GitProgressStep { detail?: string; } -function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boolean }) { +function GitActionIcon(props: { icon: GitActionIconName; disabled: boolean }) { const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85"; if (props.icon === "commit") { @@ -53,13 +61,11 @@ function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boole return ; } - return ; + return ; } -function gitActionModalTitle(action: GitStackedAction): string { - if (action === "commit") return "Commit your changes"; - if (action === "commit_push") return "Commit and push changes"; - return "Commit, push and open PR"; +function gitActionModalTitle(): string { + return "Commit your changes"; } function initialGitProgressSteps( @@ -119,102 +125,198 @@ function updateProgressStep( } function runActionLabel(action: GitStackedAction): string { - if (action === "commit") return "Commit"; - if (action === "commit_push") return "Commit & Push"; - return "Commit, Push & Open PR"; + if (action === "commit") return "Commit changes"; + if (action === "commit_push") return "Commit and push"; + return "Commit and create PR"; } -function isViewPrOnlyAction( - action: GitStackedAction, +function buildGitActionMenuItems( gitStatus: GitStatusResult | null, -): boolean { - if (action !== "commit_push_pr") return false; - if (!gitStatus?.openPr) return false; - return !gitStatus.hasWorkingTreeChanges && gitStatus.aheadCount === 0; + isDisabled: boolean, +): GitActionMenuItem[] { + if (!gitStatus) return []; + + const hasBranch = gitStatus.branch !== null; + const hasOpenPr = gitStatus.openPr !== null; + const hasChanges = gitStatus.hasWorkingTreeChanges; + const hasAhead = gitStatus.aheadCount > 0; + const canCommit = !isDisabled && hasChanges; + const canPush = !isDisabled && hasBranch && !hasChanges && hasAhead; + const canOpenPr = !isDisabled && hasOpenPr; + const canCreatePr = + !isDisabled && + hasBranch && + !hasOpenPr && + !hasChanges && + gitStatus.hasUpstream && + gitStatus.behindCount === 0; + + if (!hasChanges && !hasAhead && hasOpenPr) { + return [ + { + id: "pr", + label: "Open PR", + disabled: !canOpenPr, + icon: "pr", + kind: "open_pr", + }, + ]; + } + + return [ + { + id: "commit", + label: "Commit", + disabled: !canCommit, + icon: "commit", + kind: "open_modal", + }, + { + id: "push", + label: "Push", + disabled: !canPush, + icon: "push", + kind: "run_action", + action: "commit_push", + }, + hasOpenPr + ? { + id: "pr", + label: "View PR", + disabled: !canOpenPr, + icon: "pr", + kind: "open_pr", + } + : { + id: "pr", + label: "Create PR", + disabled: !canCreatePr, + icon: "pr", + kind: "run_action", + action: "commit_push_pr", + }, + ]; +} + +function buildGitModalActionOptions( + gitStatus: GitStatusResult | null, + isDisabled: boolean, +): GitModalActionOption[] { + const hasBranch = gitStatus?.branch !== null; + const hasOpenPr = gitStatus?.openPr !== null; + + return [ + { + action: "commit", + label: "Commit", + disabled: isDisabled, + icon: "commit", + }, + { + action: "commit_push", + label: "Commit and push", + disabled: isDisabled || !hasBranch, + icon: "push", + ...(!hasBranch ? { detail: "Requires an attached branch." } : {}), + }, + { + action: "commit_push_pr", + label: "Commit and create PR", + disabled: isDisabled || !hasBranch || hasOpenPr, + icon: "pr", + ...(!hasBranch + ? { detail: "Requires an attached branch." } + : hasOpenPr + ? { detail: "A PR is already open for this branch." } + : {}), + }, + ]; +} + +function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] { + return ["git", "status", gitCwd]; +} + +function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null) { + return queryOptions({ + queryKey: getGitStatusQueryKey(gitCwd), + queryFn: async () => { + if (!api || !gitCwd) { + throw new Error("Git status is unavailable."); + } + return api.git.status({ cwd: gitCwd }); + }, + enabled: !!api && !!gitCwd, + }); } export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { + const queryClient = useQueryClient(); const [isGitMenuOpen, setIsGitMenuOpen] = useState(false); - const [isGitActionRunning, setIsGitActionRunning] = useState(false); - const [gitStatus, setGitStatus] = useState(null); + const [isGitModalActionRunning, setIsGitModalActionRunning] = useState(false); const [gitActionError, setGitActionError] = useState(null); - const [gitModalAction, setGitModalAction] = useState(null); + const [isGitModalOpen, setIsGitModalOpen] = useState(false); + const [gitModalSelectedAction, setGitModalSelectedAction] = useState("commit"); const [gitModalCommitMessage, setGitModalCommitMessage] = useState(""); const [gitModalProgress, setGitModalProgress] = useState([]); const [gitModalError, setGitModalError] = useState(null); const [gitModalResult, setGitModalResult] = useState(null); const gitMenuRef = useRef(null); - const latestGitCwdRef = useRef(null); + + const { data: gitStatus = null, error: gitStatusError } = useQuery( + gitStatusQueryOptions(api, gitCwd), + ); + + const runImmediateGitActionMutation = useMutation({ + mutationFn: async (action: GitStackedAction) => { + if (!api || !gitCwd) { + throw new Error("Git action is unavailable."); + } + return api.git.runStackedAction({ cwd: gitCwd, action }); + }, + onMutate: () => { + setIsGitMenuOpen(false); + setGitActionError(null); + setGitModalError(null); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["git"] }); + }, + onError: (error) => { + setGitActionError(error instanceof Error ? error.message : "Git action failed."); + }, + }); + + const isGitActionRunning = isGitModalActionRunning || runImmediateGitActionMutation.isPending; const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning; - const gitActionMenuItems = useMemo(() => { - if (!gitStatus) return []; - - const hasBranch = gitStatus.branch !== null; - const hasOpenPr = gitStatus.openPr !== null; - const canCommit = !gitBaseDisabled && gitStatus.hasWorkingTreeChanges; - const canPush = - !gitBaseDisabled && - hasBranch && - (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0); - const canViewPr = - !gitBaseDisabled && - hasBranch && - gitStatus.behindCount === 0 && - (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0 || hasOpenPr); - - return [ - { - id: "commit", - label: "Commit", - disabled: !canCommit, - icon: "commit", - }, - { - id: "commit_push", - label: "Push", - disabled: !canPush, - icon: "push", - }, - { - id: "commit_push_pr", - label: "View PR", - disabled: !canViewPr, - icon: "pr", - }, - ]; - }, [gitBaseDisabled, gitStatus]); - - const isGitModalOpen = gitModalAction !== null; - const gitModalPreviewSteps = useMemo( - () => - gitModalAction - ? initialGitProgressSteps(gitModalAction, gitModalCommitMessage) - : ([] as GitProgressStep[]), - [gitModalAction, gitModalCommitMessage], + const gitActionMenuItems = useMemo( + () => buildGitActionMenuItems(gitStatus, gitBaseDisabled), + [gitBaseDisabled, gitStatus], ); - const gitModalSteps = gitModalProgress.length > 0 ? gitModalProgress : gitModalPreviewSteps; - - useEffect(() => { - latestGitCwdRef.current = gitCwd; - }, [gitCwd]); + const gitModalActionOptions = useMemo( + () => buildGitModalActionOptions(gitStatus, !gitStatus || isGitActionRunning), + [gitStatus, isGitActionRunning], + ); + const selectedGitModalActionOption = useMemo( + () => gitModalActionOptions.find((option) => option.action === gitModalSelectedAction) ?? null, + [gitModalActionOptions, gitModalSelectedAction], + ); + const visibleGitActionError = gitActionError ?? gitStatusError?.message; + const gitModalHasProgress = gitModalProgress.length > 0; + const gitModalSelectionMode = !gitModalHasProgress && gitModalResult === null; + const gitModalSteps = gitModalProgress; const refreshGitStatus = useCallback(async () => { - const requestCwd = gitCwd; - if (!api || !requestCwd) { - setGitStatus(null); - return; - } - - const nextStatus = await api.git.status({ cwd: requestCwd }); - if (latestGitCwdRef.current !== requestCwd) return; - setGitStatus(nextStatus); + if (!api || !gitCwd) return; + await queryClient.invalidateQueries({ queryKey: ["git"] }); setGitActionError(null); - }, [api, gitCwd]); + }, [api, gitCwd, queryClient]); - const openGitActionModal = useCallback((action: GitStackedAction) => { + const openGitActionModal = useCallback(() => { setIsGitMenuOpen(false); - setGitModalAction(action); + setIsGitModalOpen(true); + setGitModalSelectedAction("commit"); setGitModalCommitMessage(""); setGitModalProgress([]); setGitModalError(null); @@ -224,7 +326,8 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp const closeGitActionModal = useCallback(() => { if (isGitActionRunning) return; - setGitModalAction(null); + setIsGitModalOpen(false); + setGitModalSelectedAction("commit"); setGitModalCommitMessage(""); setGitModalProgress([]); setGitModalError(null); @@ -232,44 +335,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp }, [isGitActionRunning]); const runGitActionImmediately = useCallback( - async (action: GitStackedAction) => { - if (!api || !gitCwd) return; - const actionCwd = gitCwd; - - setIsGitMenuOpen(false); - setGitActionError(null); - setGitModalError(null); - setIsGitActionRunning(true); - - try { - await api.git.runStackedAction({ - cwd: actionCwd, - action, - }); - } catch (error) { - setGitActionError(error instanceof Error ? error.message : "Git action failed."); - } finally { - setIsGitActionRunning(false); - try { - if (latestGitCwdRef.current === actionCwd) { - await refreshGitStatus(); - } - } catch { - setGitStatus(null); - } - } + (action: GitStackedAction) => { + runImmediateGitActionMutation.mutate(action); }, - [api, gitCwd, refreshGitStatus], + [runImmediateGitActionMutation], ); + const openExistingPr = useCallback(() => { + setIsGitMenuOpen(false); + setGitActionError(null); + + const prUrl = gitStatus?.openPr?.url ?? null; + if (!prUrl) { + setGitActionError("No open PR found for the current branch."); + return; + } + + const popup = window.open(prUrl, "_blank", "noopener,noreferrer"); + if (!popup) { + setGitActionError("Unable to open PR. Allow popups and try again."); + } + }, [gitStatus?.openPr?.url]); + const runGitAction = useCallback(async () => { - if (!api || !gitCwd || !gitModalAction) return; + if (!api || !gitCwd || !isGitModalOpen) return; + if (!selectedGitModalActionOption || selectedGitModalActionOption.disabled) return; const actionCwd = gitCwd; - const action = gitModalAction; + const action = selectedGitModalActionOption.action; const commitMessage = gitModalCommitMessage.trim(); const includeGeneratedCommitMessage = commitMessage.length === 0; - setIsGitActionRunning(true); + setIsGitModalActionRunning(true); setGitModalError(null); setGitActionError(null); setGitModalResult(null); @@ -322,11 +418,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp } if (commitRun.commit.status === "created") { - updateStep( - "commit", - "completed", - commitRun.commit.subject ?? "Committed local changes.", - ); + updateStep("commit", "completed", commitRun.commit.subject ?? "Committed local changes."); } else { updateStep("commit", "skipped", "No local changes to commit."); } @@ -362,9 +454,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp updateStep( "pr", "completed", - prRun.pr.number - ? `Opened existing PR #${prRun.pr.number}.` - : "Opened existing PR.", + prRun.pr.number ? `Opened existing PR #${prRun.pr.number}.` : "Opened existing PR.", ); } else if (prRun.pr.status === "created") { updateStep( @@ -388,53 +478,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp return updateProgressStep(steps, active.id, "failed", message); }); } finally { - setIsGitActionRunning(false); - try { - if (latestGitCwdRef.current === actionCwd) { - await refreshGitStatus(); - } - } catch { - setGitStatus(null); - } + setIsGitModalActionRunning(false); + await refreshGitStatus().catch(() => undefined); } - }, [api, gitCwd, gitModalAction, gitModalCommitMessage, refreshGitStatus]); + }, [ + api, + gitCwd, + gitModalCommitMessage, + isGitModalOpen, + refreshGitStatus, + selectedGitModalActionOption, + ]); useEffect(() => { setGitActionError(null); setGitModalError(null); - setGitModalAction(null); + setIsGitModalOpen(false); + setGitModalSelectedAction("commit"); setGitModalCommitMessage(""); setGitModalProgress([]); setGitModalResult(null); }, [gitCwd]); useEffect(() => { - let cancelled = false; - if (!api || !gitCwd) { - setGitStatus(null); - return; + if (!isGitModalOpen) return; + if (selectedGitModalActionOption && !selectedGitModalActionOption.disabled) return; + + const fallback = gitModalActionOptions.find((option) => !option.disabled); + if (fallback) { + setGitModalSelectedAction(fallback.action); } - - const load = async () => { - try { - const nextStatus = await api.git.status({ cwd: gitCwd }); - if (!cancelled) { - setGitStatus(nextStatus); - setGitActionError(null); - } - } catch (error) { - if (!cancelled) { - setGitStatus(null); - setGitActionError(error instanceof Error ? error.message : "Failed to read git status."); - } - } - }; - - void load(); - return () => { - cancelled = true; - }; - }, [api, gitCwd]); + }, [gitModalActionOptions, isGitModalOpen, selectedGitModalActionOption]); useEffect(() => { if (!isGitMenuOpen) return; @@ -492,16 +566,24 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp {gitActionMenuItems.map((item) => { return ( -
Branch @@ -592,11 +667,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
Changes - {gitStatus?.hasWorkingTreeChanges ? "Working tree has changes" : "No local changes"} + {gitStatus?.hasWorkingTreeChanges + ? "Working tree has changes" + : "No local changes"}
-
-

Next steps

- {gitModalSteps.map((step, index) => { - const borderClass = - index < gitModalSteps.length - 1 ? "border-b border-border/70" : ""; - const statusTextClass = - step.status === "failed" - ? "text-rose-500 dark:text-rose-300" - : step.status === "completed" - ? "text-emerald-600 dark:text-emerald-300" - : "text-muted-foreground/70"; + {gitModalSelectionMode && + gitModalActionOptions.map((option, index) => { + const borderClass = + index < gitModalActionOptions.length - 1 ? "border-b border-border/70" : ""; + const selected = option.action === gitModalSelectedAction && !option.disabled; - return ( -
- - {step.status === "running" ? ( - - ) : step.status === "completed" ? ( - - ) : step.status === "skipped" ? ( - - ) : step.status === "failed" ? ( - - ) : ( - - )} - -
-

{step.label}

- {step.detail && ( -

{step.detail}

- )} + return ( + + ); + })} + {!gitModalSelectionMode && + gitModalSteps.map((step, index) => { + const borderClass = + index < gitModalSteps.length - 1 ? "border-b border-border/70" : ""; + const statusTextClass = + step.status === "failed" + ? "text-rose-500 dark:text-rose-300" + : step.status === "completed" + ? "text-emerald-600 dark:text-emerald-300" + : "text-muted-foreground/70"; + + return ( +
+ + {step.status === "running" ? ( + + ) : step.status === "completed" ? ( + + ) : step.status === "skipped" ? ( + + ) : step.status === "failed" ? ( + + ) : ( + + )} + +
+

{step.label}

+ {step.detail && ( +

{step.detail}

+ )} +
-
- ); - })} + ); + })}
- - {(gitModalError ?? gitActionError) && ( + {(gitModalError ?? visibleGitActionError) && (
- {gitModalError ?? gitActionError} + {gitModalError ?? visibleGitActionError}
)} - {gitModalResult?.pr.url && (
PR:{" "} @@ -680,7 +789,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
)} -
)}
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9e18a0ac0f90380721cf3669c08058a77f49f65a --- /dev/null +++ b/apps/web/src/components/Icons.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from "react"; + +export type Icon = React.FC>; + +export const GitHubIcon: Icon = (props) => ( + + + +);