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

512 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
IconAlertTriangle,
IconCloudDownload,
IconCloudUpload,
IconChevronDown,
IconGitCherryPick,
IconGitCommit,
IconGitMerge,
IconGitPullRequest,
IconLoader2,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { RemoteContributionActionItems } from "@/components/task/remote-contribution-action-items";
import type { ReactNode } from "react";
export type PerRepoStatus = {
repository_name: string;
branch: string | null;
ahead: number;
behind: number;
pushAhead: number;
pullBehind: number;
hasStaged: boolean;
hasUnstaged: boolean;
};
export type PerRepoCallbacks = {
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 type PrimaryButtonConfig = {
icon: ReactNode;
label: string;
badge: number | null;
tooltip: string;
};
/**
* One complete sentence per action rather than a single frame with the action
* name interpolated. `primaryButtonConfig.label` is itself already translated,
* and a translated value dropped into a translated frame cannot be declined to
* agree with the surrounding grammar (genitive in German/Russian, and so on) —
* nor can the frame be reordered around it. Keyed off `primaryAction`, which is
* an untranslated discriminant.
*/
const PICK_REPOSITORY_KEYS: Record<"commit" | "push" | "pr" | "rebase", string> = {
commit: "integrations:pickARepositoryForCommit",
push: "integrations:pickARepositoryForPush",
pr: "integrations:pickARepositoryForCreatePr",
rebase: "integrations:pickARepositoryForRebase",
};
const ITEM_CLASS = "cursor-pointer gap-3";
const ICON_CLASS = "h-4 w-4 text-muted-foreground";
const PUSH_ACTIONS: VcsActionKey[] = ["push", "force-push"];
export type VcsActionKey = "commit" | "push" | "pr" | "pull" | "rebase" | "merge" | "force-push";
export function isRemoteActionBlockedForRepository(
action: VcsActionKey,
repositoryName: string,
blockedRepositoryName: string | undefined,
pushDisabled: boolean,
pullDisabled: boolean,
): boolean {
const actionBlocked =
(PUSH_ACTIONS.includes(action) ? pushDisabled : false) ||
(action === "pull" ? pullDisabled : false);
return actionBlocked && (!blockedRepositoryName || repositoryName === blockedRepositoryName);
}
function shouldHideContributionAction(
action: ActionDef,
repo: string,
hideBlockedContribution: boolean,
blockedRepositoryName: string | undefined,
) {
return (
hideBlockedContribution &&
PUSH_ACTIONS.includes(action.key) &&
blockedRepositoryName !== undefined &&
repo === blockedRepositoryName
);
}
type ActionDef = {
key: VcsActionKey;
/** Catalog key, resolved at render: `t()` here would freeze at the boot locale. */
labelKey: string;
icon: ReactNode;
/** Returns true when this action should be disabled for a given repo. */
disabledFor: (status: PerRepoStatus | undefined) => boolean;
invoke: (repo: string, callbacks: PerRepoCallbacks) => void;
};
function PerRepoActionItem({
action,
repo,
status,
disabled,
pushDisabled,
pullDisabled,
pushDisabledReason,
pullDisabledReason,
blockedRepositoryName,
repoDisplayName,
callbacks,
}: {
action: ActionDef;
repo: string;
status: PerRepoStatus | undefined;
disabled: boolean;
pushDisabled: boolean;
pullDisabled: boolean;
pushDisabledReason: string | undefined;
pullDisabledReason: string | undefined;
blockedRepositoryName: string | undefined;
repoDisplayName: (repositoryName: string) => string | undefined;
callbacks: PerRepoCallbacks;
}) {
const { t } = useTranslation();
const ahead = status?.pushAhead ?? 0;
const behind = status?.pullBehind ?? 0;
const label = repoDisplayName(repo) || repo || t("integrations:repository");
const actionBlocked = isRemoteActionBlockedForRepository(
action.key,
repo,
blockedRepositoryName,
pushDisabled,
pullDisabled,
);
let actionDisabledReason: string | undefined;
if (actionBlocked) {
actionDisabledReason = PUSH_ACTIONS.includes(action.key)
? (pushDisabledReason ?? t("task:divergedActionsUnavailable"))
: (pullDisabledReason ?? t("task:divergedActionsUnavailable"));
}
return (
<DropdownMenuItem
className={ITEM_CLASS}
onClick={() => action.invoke(repo, callbacks)}
disabled={disabled || actionBlocked || action.disabledFor(status)}
title={actionDisabledReason}
>
<span className="flex-1 truncate">{label}</span>
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
{ahead > 0 && <span>↑{ahead}</span>}
{behind > 0 && <span className="text-yellow-500">↓{behind}</span>}
</span>
</DropdownMenuItem>
);
}
const ACTION_DEFS: ActionDef[] = [
{
key: "commit",
labelKey: "integrations:commit",
icon: <IconGitCommit className={ICON_CLASS} />,
disabledFor: (s) => !((s?.hasStaged ?? false) || (s?.hasUnstaged ?? false)),
invoke: (repo, cb) => cb.onCommit(repo),
},
{
key: "push",
labelKey: "integrations:push",
icon: <IconCloudUpload className={ICON_CLASS} />,
disabledFor: (s) => (s?.pushAhead ?? 0) === 0,
invoke: (repo, cb) => cb.onPush(false, repo),
},
{
key: "pr",
labelKey: "integrations:createPr",
icon: <IconGitPullRequest className={ICON_CLASS} />,
disabledFor: () => false,
invoke: (repo, cb) => cb.onPR(repo),
},
{
key: "pull",
labelKey: "integrations:pull",
icon: <IconCloudDownload className={ICON_CLASS} />,
disabledFor: () => false,
invoke: (repo, cb) => cb.onPull(repo),
},
{
key: "rebase",
labelKey: "integrations:rebase",
icon: <IconGitCherryPick className={ICON_CLASS} />,
disabledFor: () => false,
invoke: (repo, cb) => cb.onRebase(repo),
},
{
key: "merge",
labelKey: "integrations:merge",
icon: <IconGitMerge className={ICON_CLASS} />,
disabledFor: () => false,
invoke: (repo, cb) => cb.onMerge(repo),
},
{
key: "force-push",
labelKey: "integrations:forcePush",
icon: <IconAlertTriangle className={ICON_CLASS} />,
disabledFor: (s) => (s?.pushAhead ?? 0) === 0,
invoke: (repo, cb) => cb.onPush(true, repo),
},
];
/**
* Sub-menu listing every repo for one git action. Per-repo entries show the
* repo name plus its ahead/behind indicators so the user can pick the right
* target without leaving the menu. Disabled when the action is a no-op for
* that repo (e.g. Push when ahead == 0).
*/
function PerRepoActionSub({
action,
disabled,
pushDisabled,
pullDisabled,
pushDisabledReason,
pullDisabledReason,
blockedRepositoryName,
hideBlockedContribution,
repoNames,
perRepoStatus,
repoDisplayName,
callbacks,
}: {
action: ActionDef;
disabled: boolean;
pushDisabled: boolean;
pullDisabled: boolean;
pushDisabledReason?: string;
pullDisabledReason?: string;
blockedRepositoryName: string | undefined;
hideBlockedContribution: boolean;
repoNames: string[];
perRepoStatus: PerRepoStatus[];
repoDisplayName: (repositoryName: string) => string | undefined;
callbacks: PerRepoCallbacks;
}) {
const { t } = useTranslation();
const statusByName = new Map(perRepoStatus.map((s) => [s.repository_name, s]));
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={ITEM_CLASS} disabled={disabled}>
{action.icon}
<span className="flex-1">{t(action.labelKey)}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
{repoNames.map((repo) => {
if (
shouldHideContributionAction(
action,
repo,
hideBlockedContribution,
blockedRepositoryName,
)
) {
return null;
}
return (
<PerRepoActionItem
key={repo || "__no_repo__"}
action={action}
repo={repo}
status={statusByName.get(repo)}
disabled={disabled}
pushDisabled={pushDisabled}
pullDisabled={pullDisabled}
pushDisabledReason={pushDisabledReason}
pullDisabledReason={pullDisabledReason}
blockedRepositoryName={blockedRepositoryName}
repoDisplayName={repoDisplayName}
callbacks={callbacks}
/>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
/**
* Action-first dropdown for multi-repo workspaces. Top level lists each git
* action (Commit / Push / PR / Pull / Rebase / Merge / Force Push); each
* action expands to a sub-menu of per-repo entries with ahead/behind
* indicators. Top-level stays a constant 7 items regardless of repo count
* so the menu doesn't grow unbounded with N repos (the previous repo-first
* layout was 7 × N items, which scrolled off-screen at 3+ repos).
*/
function MultiRepoVcsDropdown({
disabled,
baseBranch,
repoNames,
perRepoStatus,
pushDisabled,
pullDisabled,
pushDisabledReason,
pullDisabledReason,
showContributionResolution,
replaceDisabled,
useDisabled,
blockedRepositoryName,
repoDisplayName,
callbacks,
prNumber,
}: {
disabled: boolean;
baseBranch: string;
repoNames: string[];
perRepoStatus: PerRepoStatus[];
pushDisabled: boolean;
pullDisabled: boolean;
pushDisabledReason?: string;
pullDisabledReason?: string;
showContributionResolution: boolean;
replaceDisabled: boolean;
useDisabled: boolean;
blockedRepositoryName: string | undefined;
repoDisplayName: (repositoryName: string) => string | undefined;
callbacks: PerRepoCallbacks;
prNumber?: number;
}) {
const { t } = useTranslation();
return (
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
{t("integrations:pickActionThenRepo")}
</DropdownMenuLabel>
{ACTION_DEFS.map((action, idx) => (
<div key={action.key}>
{/* Force Push is the destructive variant of Push; group it visually
by separating it from the everyday actions above. */}
{action.key === "force-push" && idx > 0 && <DropdownMenuSeparator />}
<PerRepoActionSub
action={action}
disabled={disabled}
pushDisabled={pushDisabled}
pullDisabled={pullDisabled}
pushDisabledReason={pushDisabledReason}
pullDisabledReason={pullDisabledReason}
blockedRepositoryName={blockedRepositoryName}
hideBlockedContribution={showContributionResolution}
repoNames={repoNames}
perRepoStatus={perRepoStatus}
repoDisplayName={repoDisplayName}
callbacks={callbacks}
/>
{action.key === "rebase" && <RebaseMergeFootnote target={baseBranch} type="onto" />}
{action.key === "merge" && <RebaseMergeFootnote target={baseBranch} type="from" />}
</div>
))}
{showContributionResolution && blockedRepositoryName !== undefined && (
<>
<DropdownMenuSeparator />
<RemoteContributionActionItems
disabled={disabled}
replaceDisabled={replaceDisabled}
useDisabled={useDisabled}
itemClassName={ITEM_CLASS}
onReplaceContribution={() => callbacks.onReplaceContribution(blockedRepositoryName)}
onUseContribution={() => callbacks.onUseContribution(blockedRepositoryName)}
onViewPRVersion={() => callbacks.onViewContribution(blockedRepositoryName)}
onCompareVersions={() => callbacks.onCompareContribution(blockedRepositoryName)}
prNumber={prNumber}
replaceLabelKey="task:publishTaskVersion"
useLabelKey="task:restorePublishedPRVersion"
viewLabelKey="task:openPROnGitHub"
replaceDescriptionKey="task:remoteContributionPublishDescription"
useDescriptionKey="task:remoteContributionRestoreDescription"
/>
</>
)}
</DropdownMenuContent>
);
}
/** Inline footnote under Rebase/Merge showing the target branch. */
// `type` stays a discriminant and never reaches the screen: rendering the union
// member directly would make "onto"/"from" untranslatable and couple the copy to
// the logic. Each branch resolves a whole message with the ref interpolated.
function RebaseMergeFootnote({ target, type }: { target: string; type: "onto" | "from" }) {
const { t } = useTranslation();
return (
<div className="px-3 py-0.5 text-[10px] text-muted-foreground/60">
{type === "onto"
? t("integrations:ontoBranch", { branch: target })
: t("integrations:fromBranch", { branch: target })}
</div>
);
}
/**
* Multi-repo top-bar button. The primary button click opens the per-repo
* dropdown directly (no separate chevron) since every action needs a repo
* scope. The label / badge / tooltip mirror the single-repo strongest action
* (Commit / Push / Create PR / Rebase) so the visual CTA is preserved.
*/
export function MultiRepoVcsButton({
primaryButtonConfig,
primaryAction,
isDisabled,
isGitLoading,
baseBranch,
repoNames,
perRepoStatus,
pushDisabled,
pullDisabled,
pushDisabledReason,
pullDisabledReason,
showContributionResolution,
replaceDisabled,
useDisabled,
blockedRepositoryName,
repoDisplayName,
callbacks,
prNumber,
}: {
primaryButtonConfig: PrimaryButtonConfig;
primaryAction: "commit" | "push" | "pr" | "rebase";
isDisabled: boolean;
isGitLoading: boolean;
baseBranch: string;
repoNames: string[];
perRepoStatus: PerRepoStatus[];
pushDisabled: boolean;
pullDisabled: boolean;
pushDisabledReason?: string;
pullDisabledReason?: string;
showContributionResolution: boolean;
replaceDisabled: boolean;
useDisabled: boolean;
blockedRepositoryName?: string;
repoDisplayName: (repositoryName: string) => string | undefined;
callbacks: PerRepoCallbacks;
prNumber?: number;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="cursor-pointer gap-1"
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>
)}
{isGitLoading ? (
<IconLoader2 className="h-3.5 w-3.5 animate-spin ml-1" />
) : (
<IconChevronDown className="h-3.5 w-3.5 ml-1" />
)}
</Button>
</DropdownMenuTrigger>
<MultiRepoVcsDropdown
disabled={isDisabled}
baseBranch={baseBranch}
repoNames={repoNames}
perRepoStatus={perRepoStatus}
pushDisabled={pushDisabled}
pullDisabled={pullDisabled}
pushDisabledReason={pushDisabledReason}
pullDisabledReason={pullDisabledReason}
showContributionResolution={showContributionResolution}
replaceDisabled={replaceDisabled}
useDisabled={useDisabled}
blockedRepositoryName={blockedRepositoryName}
repoDisplayName={repoDisplayName}
callbacks={callbacks}
prNumber={prNumber}
/>
</DropdownMenu>
</span>
</TooltipTrigger>
<TooltipContent>{t(PICK_REPOSITORY_KEYS[primaryAction])}</TooltipContent>
</Tooltip>
);
}