"use client"; import { memo, useCallback, type ComponentProps, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { IconGitCommit, IconGitPullRequest, IconGitMerge, IconGitCherryPick, IconLoader2, IconChevronDown, IconCloudDownload, IconCloudUpload, IconAlertTriangle, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, } from "@kandev/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@kandev/ui/lib/utils"; import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { useVcsDialogs } from "@/components/vcs/vcs-dialogs"; import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; import { MultiRepoVcsButton } from "@/components/vcs-multi-repo-menu"; const DEFAULT_BASE_BRANCH = "origin/main"; 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"; } 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 DivergenceTone = "ahead" | "behind"; const divergenceToneClass: Record = { ahead: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500", behind: "border-yellow-500/40 bg-yellow-500/10 text-yellow-500", }; function DivergencePill({ tone, value, ariaLabel, }: { tone: DivergenceTone; value: number; ariaLabel: string; }) { if (value <= 0) return null; return ( {tone === "ahead" ? "↑" : "↓"} {value} ); } function GitDivergencePills({ ahead, behind }: { ahead: number; behind: number }) { const { t } = useTranslation(); if (ahead <= 0 && behind <= 0) return null; // `{{value}}`, not `{{count}}`: the shipped English is count-invariant // ("1 commits ahead"), and switching to a plural here would change the // accessible name that E2E specs query by. return ( ); } type VcsDropdownItemsProps = { disabled: boolean; baseBranch?: string; hasMatchingUpstream: boolean | "" | null | undefined; behindCount: number; aheadCount: number; onPR: () => void; onPull: () => void; onPush: (force: boolean) => void; onRebase: () => void; onMerge: () => void; }; function VcsDropdownItems({ disabled, baseBranch, hasMatchingUpstream, behindCount, aheadCount, onPR, onPull, onPush, onRebase, onMerge, }: VcsDropdownItemsProps) { const { t } = useTranslation(); return ( {t("integrations:createPr")} {t("integrations:pull")} {hasMatchingUpstream && behindCount > 0 && ( ↓{behindCount} )} {t("integrations:push")} {hasMatchingUpstream && aheadCount > 0 && ( ↑{aheadCount} )} onPush(false)} disabled={disabled} > {t("integrations:push")} onPush(true)} disabled={disabled} > {t("integrations:forcePush")} {t("integrations:rebase")} {/* The branch is a git ref — data. One message with the ref interpolated, not an "onto" stem concatenated onto it. */} {t("integrations:ontoBranch", { branch: baseBranch || DEFAULT_BASE_BRANCH })} {t("integrations:merge")} {t("integrations:fromBranch", { branch: baseBranch || DEFAULT_BASE_BRANCH })} ); } type VcsSplitButtonProps = { sessionId: string | null; baseBranch?: string; buttonSize?: ComponentProps["size"]; className?: string; }; // The `label` values below stay English on purpose. They are operation *names* // consumed by `useGitWithFeedback`, which concatenates them into its own English // toast titles (`${operationName} failed`). `hooks/use-git-with-feedback.ts` is // not migrated yet, so translating only this half would ship mixed-language // toasts. They move when that hook's messages do. function useGitActions(git: ReturnType, baseBranch?: string) { const gitWithFeedback = useGitWithFeedback(); const handlePull = useCallback( (repo?: string) => { const label = repo ? `Pull (${repo})` : "Pull"; gitWithFeedback(() => git.pull(false, repo), label); }, [gitWithFeedback, git], ); const handlePush = useCallback( (force = false, repo?: string) => { const baseLabel = force ? "Force Push" : "Push"; const label = repo ? `${baseLabel} (${repo})` : baseLabel; gitWithFeedback(() => git.push({ force }, repo), label); }, [gitWithFeedback, git], ); const handleRebase = useCallback( (repo?: string) => { const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; const label = repo ? `Rebase (${repo})` : "Rebase"; gitWithFeedback(() => git.rebase(targetBranch, repo), label); }, [gitWithFeedback, git, baseBranch], ); const handleMerge = useCallback( (repo?: string) => { const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; const label = repo ? `Merge (${repo})` : "Merge"; gitWithFeedback(() => git.merge(targetBranch, repo), label); }, [gitWithFeedback, git, baseBranch], ); return { handlePull, handlePush, handleRebase, handleMerge }; } const VcsSplitButton = memo(function VcsSplitButton({ sessionId, baseBranch, buttonSize = "sm", className, }: VcsSplitButtonProps) { const { t } = useTranslation(); const git = useSessionGit(sessionId); const { openCommitDialog, openPRDialog } = useVcsDialogs(); const activePR = useActiveTaskPR(); const hasOpenPR = activePR?.state === "open"; const { handlePull, handlePush, handleRebase, handleMerge } = useGitActions(git, baseBranch); const repoDisplayName = useRepoDisplayName(sessionId); const currentBranch = git.branch; const remoteBranch = git.remoteBranch; const hasMatchingUpstream = remoteBranch && currentBranch && remoteBranch === `origin/${currentBranch}`; const uncommittedFileCount = git.allFiles.length; const aheadCount = git.ahead; const behindCount = git.behind; const isDisabled = git.isLoading || !sessionId; const isGitLoading = git.isLoading; // Multi-repo when there's more than one named repo. Single-repo workspaces // get either a single empty-name entry or no entries at all in repoNames. const isMultiRepo = git.repoNames.length > 1; 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"; if (isMultiRepo) { return ( openCommitDialog(repo), onPR: (repo) => openPRDialog(repo), onPull: handlePull, onPush: handlePush, onRebase: handleRebase, onMerge: handleMerge, }} /> ); } return ( openPRDialog()} onPull={() => handlePull()} onPush={(force) => handlePush(force)} onRebase={() => handleRebase()} onMerge={() => handleMerge()} /> ); }); function SingleRepoVcsButton({ primaryButtonConfig, primaryAction, isDisabled, isGitLoading, baseBranch, hasMatchingUpstream, behindCount, aheadCount, onPR, onPull, onPush, onRebase, onMerge, className, buttonSize = "sm", showDivergencePills = false, }: { primaryButtonConfig: PrimaryButtonConfig; primaryAction: "commit" | "push" | "pr" | "rebase"; isDisabled: boolean; isGitLoading: boolean; baseBranch?: string; hasMatchingUpstream: boolean | "" | null | undefined; behindCount: number; aheadCount: number; onPR: () => void; onPull: () => void; onPush: (force: boolean) => void; onRebase: () => void; onMerge: () => void; className?: string; buttonSize?: ComponentProps["size"]; showDivergencePills?: boolean; }) { const { t } = useTranslation(); return (
{primaryButtonConfig.tooltip}
); } export { VcsSplitButton };