Files
kandev/apps/web/components/vcs-split-button-parts.tsx
2026-09-14 09:28:23 +01:00

466 lines
14 KiB
TypeScript

"use client";
import { useTranslation } from "react-i18next";
import type { ComponentProps } from "react";
import { IconChevronDown, IconLoader2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { DropdownMenu, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@kandev/ui/lib/utils";
import {
MultiRepoVcsButton,
type PerRepoCallbacks,
type PerRepoStatus,
} from "./vcs-multi-repo-menu";
import { VcsDropdownItems } from "./vcs-split-button-dropdown";
import type { PrimaryButtonConfig } from "./vcs-split-button";
import {
type RemoteContributionResolutionTarget,
type useRemoteContributionResolution,
} from "@/components/task/use-remote-contribution-resolution";
import { RemoteContributionResolutionDialog } from "@/components/task/remote-contribution-resolution-dialog";
import { AHEAD_MARK, BEHIND_MARK, DEFAULT_BASE_BRANCH } from "./vcs-constants";
type DivergenceTone = "ahead" | "behind";
const divergenceToneClass: Record<DivergenceTone, string> = {
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 (
<span
aria-label={ariaLabel}
className={cn(
"inline-flex h-5 items-center rounded-md border px-1.5 text-[11px] font-semibold leading-none tabular-nums",
divergenceToneClass[tone],
)}
>
{tone === "ahead" ? AHEAD_MARK : BEHIND_MARK}
{value}
</span>
);
}
export 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 (
<span className="ml-1 inline-flex items-center gap-1">
<DivergencePill
tone="ahead"
value={ahead}
ariaLabel={t("integrations:commitsAheadAriaLabel", { value: ahead })}
/>
<DivergencePill
tone="behind"
value={behind}
ariaLabel={t("integrations:commitsBehindAriaLabel", { value: behind })}
/>
</span>
);
}
type SingleRepoVcsButtonProps = {
primaryButtonConfig: PrimaryButtonConfig;
primaryAction: "commit" | "push" | "pr" | "rebase";
isDisabled: boolean;
isGitLoading: boolean;
baseBranch?: string;
hasUpstream: boolean;
behindCount: number;
aheadCount: number;
pushDisabled: boolean;
pullDisabled: boolean;
pushDisabledReason?: string;
pullDisabledReason?: string;
showContributionResolution: boolean;
replaceDisabled: boolean;
useDisabled: boolean;
onPR: () => void;
onPull: () => void;
onPush: (force: boolean) => void;
onReplaceContribution: () => void;
onUseContribution: () => void;
onViewPRVersion: () => void;
onCompareContribution: () => void;
prNumber?: number;
onRebase: () => void;
onMerge: () => void;
className?: string;
buttonSize?: ComponentProps<typeof Button>["size"];
showDivergencePills?: boolean;
};
function SingleRepoVcsButton({
primaryButtonConfig,
primaryAction,
isDisabled,
isGitLoading,
baseBranch,
hasUpstream,
behindCount,
aheadCount,
pushDisabled,
pullDisabled,
pushDisabledReason,
pullDisabledReason,
showContributionResolution,
replaceDisabled,
useDisabled,
onPR,
onPull,
onPush,
onReplaceContribution,
onUseContribution,
onViewPRVersion,
onCompareContribution,
prNumber,
onRebase,
onMerge,
className,
buttonSize = "sm",
showDivergencePills = false,
}: SingleRepoVcsButtonProps) {
const { t } = useTranslation();
return (
<div className={cn("inline-flex", className)}>
<Tooltip>
<TooltipTrigger asChild>
<Button
size={buttonSize}
variant="outline"
className="rounded-r-none border-r-0 cursor-pointer"
onClick={primaryButtonConfig.onClick}
disabled={isDisabled}
data-testid={`vcs-primary-${primaryAction}`}
>
{primaryButtonConfig.icon}
{primaryButtonConfig.label}
{primaryButtonConfig.badge != null && (
<span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{primaryButtonConfig.badge}
</span>
)}
{showDivergencePills && <GitDivergencePills ahead={aheadCount} behind={behindCount} />}
</Button>
</TooltipTrigger>
<TooltipContent>{primaryButtonConfig.tooltip}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size={buttonSize}
variant="outline"
className="-ml-px rounded-l-none px-2 cursor-pointer"
aria-label={t("integrations:openVcsOptions")}
disabled={isDisabled}
>
{isGitLoading ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconChevronDown className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<VcsDropdownItems
disabled={isDisabled}
baseBranch={baseBranch}
hasUpstream={hasUpstream}
behindCount={behindCount}
aheadCount={aheadCount}
pushDisabled={pushDisabled}
pullDisabled={pullDisabled}
pushDisabledReason={pushDisabledReason}
pullDisabledReason={pullDisabledReason}
showContributionResolution={showContributionResolution}
replaceDisabled={replaceDisabled}
useDisabled={useDisabled}
onPR={onPR}
onPull={onPull}
onPush={onPush}
onReplaceContribution={onReplaceContribution}
onUseContribution={onUseContribution}
onViewPRVersion={onViewPRVersion}
onCompareVersions={onCompareContribution}
prNumber={prNumber}
onRebase={onRebase}
onMerge={onMerge}
/>
</DropdownMenu>
</div>
);
}
export type VcsSplitButtonCallbacks = {
onCommit: (repo?: string) => void;
onPR: (repo?: string) => void;
onPull: (repo?: string) => void;
onPush: (force: boolean, repo?: string) => void;
onRebase: (repo?: string) => void;
onMerge: (repo?: string) => void;
onReplaceContribution: (repo?: string) => void;
onUseContribution: (repo?: string) => void;
onViewContribution: (repo?: string) => void;
onCompareContribution: (repo?: string) => void;
};
export function buildSingleRepoContributionCallbacks(
callbacks: Pick<
VcsSplitButtonCallbacks,
"onReplaceContribution" | "onUseContribution" | "onViewContribution" | "onCompareContribution"
>,
blockedRepositoryName?: string,
) {
const repositoryScope = blockedRepositoryName ?? "";
return {
onReplaceContribution: () => callbacks.onReplaceContribution(repositoryScope),
onUseContribution: () => callbacks.onUseContribution(repositoryScope),
onViewContribution: () => callbacks.onViewContribution(repositoryScope),
onCompareContribution: () => callbacks.onCompareContribution(repositoryScope),
};
}
type VcsSplitButtonContentProps = {
isMultiRepo: boolean;
primaryButtonConfig: PrimaryButtonConfig;
primaryAction: "commit" | "push" | "pr" | "rebase";
isDisabled: boolean;
isGitLoading: boolean;
baseBranch?: string;
hasUpstream: boolean;
behindCount: number;
aheadCount: number;
pushDisabled: boolean;
pullDisabled: boolean;
pushDisabledReason?: string;
pullDisabledReason?: string;
showContributionResolution: boolean;
replaceDisabled: boolean;
useDisabled: boolean;
repoNames: string[];
perRepoStatus: PerRepoStatus[];
blockedRepositoryName?: string;
repoDisplayName: (repositoryName: string) => string | undefined;
buttonSize?: ComponentProps<typeof Button>["size"];
className?: string;
showDivergencePills: boolean;
callbacks: VcsSplitButtonCallbacks;
resolution: ReturnType<typeof useRemoteContributionResolution>;
resolutionTarget: RemoteContributionResolutionTarget | null;
confirmResolution: () => Promise<void>;
prNumber?: number;
};
type SingleRepoVcsContentProps = Omit<
VcsSplitButtonContentProps,
"isMultiRepo" | "repoNames" | "perRepoStatus" | "repoDisplayName"
>;
function ContributionResolutionDialog({
resolution,
resolutionTarget,
confirmResolution,
}: Pick<VcsSplitButtonContentProps, "resolution" | "resolutionTarget" | "confirmResolution">) {
if (!resolution.pending || !resolutionTarget) return null;
return (
<RemoteContributionResolutionDialog
open
action={resolution.pending.action}
repositoryName={resolutionTarget.repositoryName ?? ""}
expectedRemoteHead={resolution.pending.expectedRemoteHead}
isLoading={resolution.isLoading}
errorKey={resolution.errorKey}
onOpenChange={(open) => {
if (!open) resolution.cancel();
}}
onConfirm={() => {
void confirmResolution();
}}
/>
);
}
function MultiRepoVcsContent({
primaryButtonConfig,
primaryAction,
isDisabled,
isGitLoading,
baseBranch,
repoNames,
perRepoStatus,
pushDisabled,
pullDisabled,
pushDisabledReason,
pullDisabledReason,
showContributionResolution,
replaceDisabled,
useDisabled,
blockedRepositoryName,
repoDisplayName,
callbacks,
resolution,
resolutionTarget,
confirmResolution,
prNumber,
}: Pick<
VcsSplitButtonContentProps,
| "primaryButtonConfig"
| "primaryAction"
| "isDisabled"
| "isGitLoading"
| "baseBranch"
| "repoNames"
| "perRepoStatus"
| "pushDisabled"
| "pullDisabled"
| "pushDisabledReason"
| "pullDisabledReason"
| "showContributionResolution"
| "replaceDisabled"
| "useDisabled"
| "blockedRepositoryName"
| "repoDisplayName"
| "callbacks"
| "resolution"
| "resolutionTarget"
| "confirmResolution"
| "prNumber"
>) {
const multiRepoCallbacks: PerRepoCallbacks = {
onCommit: (repo) => callbacks.onCommit(repo),
onPR: (repo) => callbacks.onPR(repo),
onPull: (repo) => callbacks.onPull(repo),
onPush: (force, repo) => callbacks.onPush(force, repo),
onRebase: (repo) => callbacks.onRebase(repo),
onMerge: (repo) => callbacks.onMerge(repo),
onReplaceContribution: (repo) => callbacks.onReplaceContribution(repo),
onUseContribution: (repo) => callbacks.onUseContribution(repo),
onViewContribution: (repo) => callbacks.onViewContribution(repo),
onCompareContribution: (repo) => callbacks.onCompareContribution(repo),
};
return (
<>
<MultiRepoVcsButton
primaryButtonConfig={primaryButtonConfig}
primaryAction={primaryAction}
isDisabled={isDisabled}
isGitLoading={isGitLoading}
baseBranch={baseBranch || DEFAULT_BASE_BRANCH}
repoNames={repoNames}
perRepoStatus={perRepoStatus}
pushDisabled={pushDisabled}
pullDisabled={pullDisabled}
pushDisabledReason={pushDisabledReason}
pullDisabledReason={pullDisabledReason}
showContributionResolution={showContributionResolution}
replaceDisabled={replaceDisabled}
useDisabled={useDisabled}
blockedRepositoryName={blockedRepositoryName}
repoDisplayName={repoDisplayName}
callbacks={multiRepoCallbacks}
prNumber={prNumber}
/>
<ContributionResolutionDialog
resolution={resolution}
resolutionTarget={resolutionTarget}
confirmResolution={confirmResolution}
/>
</>
);
}
function SingleRepoVcsContent({
primaryButtonConfig,
primaryAction,
isDisabled,
isGitLoading,
baseBranch,
hasUpstream,
behindCount,
aheadCount,
pushDisabled,
pullDisabled,
pushDisabledReason,
pullDisabledReason,
showContributionResolution,
replaceDisabled,
useDisabled,
blockedRepositoryName,
buttonSize,
className,
showDivergencePills,
callbacks,
resolution,
resolutionTarget,
confirmResolution,
prNumber,
}: SingleRepoVcsContentProps) {
const singleRepoContributionCallbacks = buildSingleRepoContributionCallbacks(
callbacks,
blockedRepositoryName,
);
return (
<>
<SingleRepoVcsButton
primaryButtonConfig={primaryButtonConfig}
primaryAction={primaryAction}
isDisabled={isDisabled}
isGitLoading={isGitLoading}
baseBranch={baseBranch}
hasUpstream={hasUpstream}
behindCount={behindCount}
aheadCount={aheadCount}
pushDisabled={pushDisabled}
pullDisabled={pullDisabled}
pushDisabledReason={pushDisabledReason}
pullDisabledReason={pullDisabledReason}
showContributionResolution={showContributionResolution}
replaceDisabled={replaceDisabled}
useDisabled={useDisabled}
buttonSize={buttonSize}
className={className}
showDivergencePills={showDivergencePills}
onPR={() => callbacks.onPR()}
onPull={() => callbacks.onPull()}
onPush={(force) => callbacks.onPush(force)}
onReplaceContribution={singleRepoContributionCallbacks.onReplaceContribution}
onUseContribution={singleRepoContributionCallbacks.onUseContribution}
onViewPRVersion={singleRepoContributionCallbacks.onViewContribution}
onCompareContribution={singleRepoContributionCallbacks.onCompareContribution}
prNumber={prNumber}
onRebase={() => callbacks.onRebase()}
onMerge={() => callbacks.onMerge()}
/>
<ContributionResolutionDialog
resolution={resolution}
resolutionTarget={resolutionTarget}
confirmResolution={confirmResolution}
/>
</>
);
}
export function VcsSplitButtonContent(props: VcsSplitButtonContentProps) {
if (props.isMultiRepo) {
return <MultiRepoVcsContent {...props} />;
}
return <SingleRepoVcsContent {...props} />;
}