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

467 lines
15 KiB
TypeScript

"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: <IconGitCommit className="h-4 w-4" />,
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: <IconGitPullRequest className="h-4 w-4" />,
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: <IconCloudUpload className="h-4 w-4" />,
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: <IconGitCherryPick className="h-4 w-4" />,
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<typeof Button>["size"];
className?: string;
};
function useGitActions(git: ReturnType<typeof useSessionGit>, 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<typeof useRemoteContributionRelation>["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<typeof useVcsContributionState>["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<TaskPR, "pr_url"> | 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 (
<VcsSplitButtonContent
isMultiRepo={git.repoNames.length > 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 };