467 lines
15 KiB
TypeScript
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 };
|