"use client"; import { memo, useCallback, useMemo, type ComponentProps, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { IconGitCommit, IconGitPullRequest, IconGitCherryPick, IconCloudUpload, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useRemoteContributionRelation } from "@/hooks/domains/session/use-remote-contribution-relation"; import { remoteContributionActionPolicy, remoteContributionActionReasonKey, } from "@/hooks/domains/session/remote-contribution-relation"; import { gitOperationLabel, useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { useVcsDialogs } from "@/components/vcs/vcs-dialogs"; import type { TaskPR } from "@/lib/types/github"; import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; import { useAppStore } from "@/components/state-provider"; import { useNormalizedTaskReviewsState } from "@/components/task/review-panel-provider"; import type { ReviewItemSummary } from "@/lib/plugins/types"; import { buildRemoteContributionResolutionTarget, useRemoteContributionResolution, useRemoteContributionResolutionConfirmation, type RemoteContributionResolutionTarget, } from "@/components/task/use-remote-contribution-resolution"; import { openExternalLink } from "@/lib/desktop/external-links"; import { usePanelActions } from "@/hooks/use-panel-actions"; import { contributionHistoryExplanationKey, type ContributionHistoryExplanationTarget, } from "@/hooks/domains/session/use-contribution-history-explanation"; import { requestContributionComparison } from "@/components/task/remote-contribution-comparison"; import { VcsSplitButtonContent } from "./vcs-split-button-parts"; import { DEFAULT_BASE_BRANCH } from "./vcs-constants"; export function hasOpenChangeRequest( firstPartyState: string | null | undefined, reviews: readonly ReviewItemSummary[], ): boolean { const isOpen = (state: string | undefined) => state ? ["open", "opened", "draft"].includes(state.toLowerCase()) : false; return ( isOpen(firstPartyState ?? undefined) || reviews.some((review) => isOpen(review.taskStatus?.state ?? review.state)) ); } function useHasOpenChangeRequest(firstPartyState: string | null | undefined): boolean { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const { reviews } = useNormalizedTaskReviewsState(activeTaskId); return hasOpenChangeRequest(firstPartyState, reviews); } export function determinePrimaryAction( uncommittedFileCount: number, aheadCount: number, behindCount: number, hasOpenPR: boolean, ): "commit" | "push" | "pr" | "rebase" { if (uncommittedFileCount > 0) return "commit"; if (aheadCount > 0 && hasOpenPR) return "push"; if (aheadCount > 0) return "pr"; if (behindCount > 0) return "rebase"; return "commit"; } export type PrimaryButtonConfig = { icon: ReactNode; label: string; badge: number | null; tooltip: string; onClick: () => void; }; // The count-bearing tooltips go through `count` + `_one`/`_other` rather than a // concatenated `commit${n !== 1 ? "s" : ""}`: an English morpheme appended here // puts the plural rule at the call site, where no other locale can reach it. function buildCommitConfig( t: TFunction, uncommittedFileCount: number, openCommitDialog: () => void, ): PrimaryButtonConfig { return { icon: , label: t("integrations:commit"), badge: uncommittedFileCount > 0 ? uncommittedFileCount : null, tooltip: uncommittedFileCount > 0 ? t("integrations:commitChangedFiles", { count: uncommittedFileCount }) : t("integrations:noChangesToCommit"), onClick: openCommitDialog, }; } function buildPrConfig( t: TFunction, aheadCount: number, openPRDialog: () => void, ): PrimaryButtonConfig { return { icon: , label: t("integrations:createPr"), badge: null, tooltip: t("integrations:createPrCommitsAhead", { count: aheadCount }), onClick: openPRDialog, }; } function buildPushConfig( t: TFunction, aheadCount: number, handlePush: () => void, ): PrimaryButtonConfig { return { icon: , label: t("integrations:push"), badge: null, tooltip: t("integrations:pushCommitsToRemote", { count: aheadCount }), onClick: handlePush, }; } function buildRebaseConfig( t: TFunction, behindCount: number, baseBranch: string | undefined, handleRebase: () => void, ): PrimaryButtonConfig { return { icon: , label: t("integrations:rebase"), badge: behindCount > 0 ? behindCount : null, // `branch` is a git ref — data, interpolated rather than translated. tooltip: t("integrations:rebaseOntoBranchBehind", { branch: baseBranch || DEFAULT_BASE_BRANCH, behind: behindCount, }), onClick: handleRebase, }; } type PrimaryConfigArgs = { t: TFunction; primaryAction: "commit" | "push" | "pr" | "rebase"; uncommittedFileCount: number; aheadCount: number; behindCount: number; baseBranch: string | undefined; openCommitDialog: () => void; openPRDialog: () => void; handlePush: () => void; handleRebase: () => void; }; function buildPrimaryButtonConfig({ t, primaryAction, uncommittedFileCount, aheadCount, behindCount, baseBranch, openCommitDialog, openPRDialog, handlePush, handleRebase, }: PrimaryConfigArgs): PrimaryButtonConfig { if (primaryAction === "push") return buildPushConfig(t, aheadCount, handlePush); if (primaryAction === "pr") return buildPrConfig(t, aheadCount, openPRDialog); if (primaryAction === "rebase") return buildRebaseConfig(t, behindCount, baseBranch, handleRebase); return buildCommitConfig(t, uncommittedFileCount, openCommitDialog); } type VcsSplitButtonProps = { sessionId: string | null; baseBranch?: string; buttonSize?: ComponentProps["size"]; className?: string; }; function useGitActions(git: ReturnType, baseBranch?: string) { const { t } = useTranslation(); const gitWithFeedback = useGitWithFeedback(); const handlePull = useCallback( (repo?: string) => { gitWithFeedback(() => git.pull(false, repo), gitOperationLabel(t, "common:gitOpPull", repo)); }, [gitWithFeedback, git, t], ); const handlePush = useCallback( (force = false, repo?: string) => { const operationKey = force ? "common:gitOpForcePush" : "common:gitOpPush"; gitWithFeedback(() => git.push({ force }, repo), gitOperationLabel(t, operationKey, repo)); }, [gitWithFeedback, git, t], ); const handleRebase = useCallback( (repo?: string) => { const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; gitWithFeedback( () => git.rebase(targetBranch, repo), gitOperationLabel(t, "common:gitOpRebase", repo), ); }, [gitWithFeedback, git, baseBranch, t], ); const handleMerge = useCallback( (repo?: string) => { const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; gitWithFeedback( () => git.merge(targetBranch, repo), gitOperationLabel(t, "common:gitOpMerge", repo), ); }, [gitWithFeedback, git, baseBranch, t], ); return { handlePull, handlePush, handleRebase, handleMerge }; } function useVcsContributionResolution( sessionId: string | null, relation: ReturnType["relation"], repositoryScope: string, selectedPR: TaskPR | null, refreshProviderEvidence: ReturnType< typeof useRemoteContributionRelation >["refreshProviderEvidence"], ) { const { t } = useTranslation(); const resolution = useRemoteContributionResolution(sessionId, refreshProviderEvidence); const confirmResolution = useRemoteContributionResolutionConfirmation(resolution); const remoteRepositoryLabel = t("task:remoteRepository"); const resolutionTarget = useMemo( () => buildRemoteContributionResolutionTarget( relation, repositoryScope, selectedPR, remoteRepositoryLabel, ), [relation, repositoryScope, selectedPR, remoteRepositoryLabel], ); return { resolution, confirmResolution, resolutionTarget }; } function useVcsContributionState(sessionId: string | null) { const contribution = useRemoteContributionRelation(sessionId); const remoteActionPolicy = remoteContributionActionPolicy(contribution.relation); const resolutionState = useVcsContributionResolution( sessionId, contribution.relation, contribution.repositoryScope, contribution.selectedPR, contribution.refreshProviderEvidence, ); return { ...contribution, remoteActionPolicy, ...resolutionState }; } type VcsResolutionActions = Pick< ReturnType["resolution"], "requestReplace" | "requestUse" >; function requestContributionComparisonForRepo( repo: string | undefined, resolutionTarget: RemoteContributionResolutionTarget | null, contributionHistoryTarget: ContributionHistoryExplanationTarget | null, addChanges: () => void, ): void { const repositoryScope = repo ?? ""; if (!resolutionTarget || resolutionTarget.repo !== repositoryScope) return; const key = contributionHistoryExplanationKey(contributionHistoryTarget); if (!key) return; requestContributionComparison(key); addChanges(); } function buildContributionComparisonCallback( resolutionTarget: RemoteContributionResolutionTarget | null, contributionHistoryTarget: ContributionHistoryExplanationTarget | null, addChanges: () => void, ) { return (repo?: string) => requestContributionComparisonForRepo( repo, resolutionTarget, contributionHistoryTarget, addChanges, ); } export function buildVcsSplitCallbacks({ openCommitDialog, openPRDialog, handlePull, handlePush, handleRebase, handleMerge, resolution, resolutionTarget, selectedPR, openExternalLinkFn, onCompareContribution, }: { openCommitDialog: (repo?: string) => void; openPRDialog: (repo?: string) => void; handlePull: (repo?: string) => void; handlePush: (force?: boolean, repo?: string) => void; handleRebase: (repo?: string) => void; handleMerge: (repo?: string) => void; resolution: VcsResolutionActions; resolutionTarget: RemoteContributionResolutionTarget | null; selectedPR: Pick | null; openExternalLinkFn?: typeof openExternalLink; onCompareContribution?: (repo?: string) => void; }) { const openLink = openExternalLinkFn ?? openExternalLink; return { onCommit: (repo?: string) => openCommitDialog(repo), onPR: (repo?: string) => openPRDialog(repo), onPull: handlePull, onPush: handlePush, onRebase: handleRebase, onMerge: handleMerge, onReplaceContribution: (repo?: string) => { const repositoryScope = repo ?? ""; if (resolutionTarget && resolutionTarget.repo === repositoryScope) { resolution.requestReplace(resolutionTarget); } }, onUseContribution: (repo?: string) => { const repositoryScope = repo ?? ""; if (resolutionTarget && resolutionTarget.repo === repositoryScope) { resolution.requestUse(resolutionTarget); } }, onViewContribution: (repo?: string) => { const repositoryScope = repo ?? ""; if (!resolutionTarget || resolutionTarget.repo !== repositoryScope || !selectedPR?.pr_url) { return; } void openLink(selectedPR.pr_url).catch(() => undefined); }, onCompareContribution: (repo?: string) => { const repositoryScope = repo ?? ""; if (!resolutionTarget || resolutionTarget.repo !== repositoryScope) return; onCompareContribution?.(repositoryScope); }, }; } const VcsSplitButton = memo(function VcsSplitButton({ sessionId, baseBranch, buttonSize = "sm", className, }: VcsSplitButtonProps) { const { t } = useTranslation(); const git = useSessionGit(sessionId); const { openCommitDialog, openPRDialog } = useVcsDialogs(); const { relation, repositoryName, selectedPR, contributionHistoryTarget, remoteActionPolicy, resolution, confirmResolution, resolutionTarget, } = useVcsContributionState(sessionId); const hasOpenPR = useHasOpenChangeRequest(selectedPR?.state); const { handlePull, handlePush, handleRebase, handleMerge } = useGitActions(git, baseBranch); const repoDisplayName = useRepoDisplayName(sessionId); const { addChanges } = usePanelActions(); const hasUpstream = Boolean(git.remoteBranch); const uncommittedFileCount = git.allFiles.length; const aheadCount = remoteActionPolicy.pushDisabled ? 0 : git.pushAhead; const { behind: behindCount, pullBehind: pullCount } = git; const isDisabled = git.isLoading || !sessionId; const showContributionResolution = relation.action === "diverged_replace"; const pushDisabledReasonKey = remoteContributionActionReasonKey(relation, "push"); const pullDisabledReasonKey = remoteContributionActionReasonKey(relation, "pull"); const primaryAction = determinePrimaryAction( uncommittedFileCount, aheadCount, behindCount, hasOpenPR, ); const primaryButtonConfig = buildPrimaryButtonConfig({ t, primaryAction, uncommittedFileCount, aheadCount, behindCount, baseBranch, openCommitDialog, openPRDialog, handlePush: () => handlePush(false), handleRebase, }); const showDivergencePills = primaryAction !== "commit"; const onCompareContribution = buildContributionComparisonCallback( resolutionTarget, contributionHistoryTarget, addChanges, ); const callbacks = buildVcsSplitCallbacks({ openCommitDialog, openPRDialog, handlePull, handlePush, handleRebase, handleMerge, resolution, resolutionTarget, selectedPR, onCompareContribution, }); return ( 1} primaryButtonConfig={primaryButtonConfig} primaryAction={primaryAction} isDisabled={isDisabled} isGitLoading={git.isLoading} baseBranch={baseBranch} hasUpstream={hasUpstream} behindCount={pullCount} aheadCount={aheadCount} pushDisabled={remoteActionPolicy.pushDisabled} pullDisabled={remoteActionPolicy.pullDisabled} pushDisabledReason={pushDisabledReasonKey ? t(pushDisabledReasonKey) : undefined} pullDisabledReason={pullDisabledReasonKey ? t(pullDisabledReasonKey) : undefined} showContributionResolution={showContributionResolution} replaceDisabled={remoteActionPolicy.replaceDisabled} useDisabled={remoteActionPolicy.useDisabled} repoNames={git.repoNames} perRepoStatus={git.perRepoStatus} blockedRepositoryName={repositoryName} repoDisplayName={repoDisplayName} buttonSize={buttonSize} className={className} showDivergencePills={showDivergencePills} callbacks={callbacks} resolution={resolution} resolutionTarget={resolutionTarget} confirmResolution={confirmResolution} prNumber={selectedPR?.pr_number} /> ); }); export { VcsSplitButton };