import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import { Info } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
import { getForgePresentation, type Forge } from "@/git/forge";
import { ForgeBrandIcon, getForgeBrandColorMapping } from "@/git/forge-icon";
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query";
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import {
buildGitActions,
narrowPullRequestState,
type BuildGitActionsInput,
type GitAction,
type GitActions,
} from "@/git/policy";
import { deriveMergeCapability } from "@/git/merge-capability";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
useActiveWorkspaceSelection,
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import { readValidatedString } from "@/storage/validated-storage";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
const forgeMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedInfo = withUnistyles(Info, (theme) => ({ color: theme.colors.foreground }));
export function useGitActionRunner(): (action: GitAction) => void {
const toast = useToast();
return useCallback(
(action: GitAction) => {
if (action.disabled) return;
if (action.unavailableMessage) {
toast.show(action.unavailableMessage, {
durationMs: 3200,
icon: ,
});
return;
}
action.handler();
},
[toast],
);
}
/**
* The leading icon for every change-request action (create/view/merge) is the
* forge's brand mark, tinted in its brand color (the GitLab tanuki orange, etc).
* GitHub and unknown forges have no brand color and render in a neutral tone.
* The merge variants all share this one icon, so we build it once.
*/
function renderForgePrIcon(forge: Forge): ReactElement {
const icon = getForgePresentation(forge).icon;
return (
);
}
function forgeVocabulary(forge: Forge): { context: "mr" | undefined } {
return { context: getForgePresentation(forge).changeRequestContext };
}
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
}
function isActionDisabled(actionsDisabled: boolean, status: CheckoutGitActionStatus): boolean {
return actionsDisabled || status === "pending";
}
function resolveBranchLabel(input: {
currentBranch: string | null | undefined;
notGit: boolean;
notRepositoryLabel: string;
unknownLabel: string;
}): string {
if (input.currentBranch && input.currentBranch !== "HEAD") {
return input.currentBranch;
}
if (input.notGit) {
return input.notRepositoryLabel;
}
return input.unknownLabel;
}
function formatBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): string {
if (!baseRef) return fallbackLabel;
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}
type PrStatusValue = NonNullable | null;
interface DeriveGitActionsStateArgs {
isGit: boolean;
status: CheckoutStatusPayload | null;
gitStatus: CheckoutStatusPayload | null;
prStatus: PrStatusValue;
hasUncommittedChanges: boolean;
postShipArchiveSuggested: boolean;
isStatusLoading: boolean;
baseRefLabel: string;
}
interface DerivedGitActionsState {
actionsDisabled: boolean;
aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number | null;
behindOfOrigin: number | null;
hasPullRequest: boolean;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
shouldPromoteArchive: boolean;
}
interface GitCommitCounts {
aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number | null;
behindOfOrigin: number | null;
}
function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCommitCounts {
return {
aheadCount: gitStatus?.aheadBehind?.ahead ?? 0,
behindBaseCount: gitStatus?.aheadBehind?.behind ?? 0,
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? null,
behindOfOrigin: gitStatus?.behindOfOrigin ?? null,
};
}
function computeShouldPromoteArchive(input: {
hasUncommittedChanges: boolean;
postShipArchiveSuggested: boolean;
isMergedPullRequest: boolean;
}): boolean {
return (
!input.hasUncommittedChanges && (input.postShipArchiveSuggested || input.isMergedPullRequest)
);
}
function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActionsState {
const {
isGit,
status,
gitStatus,
prStatus,
hasUncommittedChanges,
postShipArchiveSuggested,
isStatusLoading,
baseRefLabel,
} = args;
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
const isMergedPullRequest = Boolean(prStatus?.isMerged);
return {
actionsDisabled,
...extractGitCommitCounts(gitStatus),
hasPullRequest: Boolean(prStatus?.url),
hasRemote: gitStatus?.hasRemote ?? false,
isPaseoOwnedWorktree,
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
shouldPromoteArchive: computeShouldPromoteArchive({
hasUncommittedChanges,
postShipArchiveSuggested,
isMergedPullRequest,
}),
};
}
interface UseGitActionsInput {
serverId: string;
cwd: string;
icons: {
commit: ReactElement;
pull: ReactElement;
push: ReactElement;
pullAndPush: ReactElement;
merge: ReactElement;
mergeFromBase: ReactElement;
archive: ReactElement;
};
}
interface UseGitActionsResult {
gitActions: GitActions;
branchLabel: string;
isGit: boolean;
}
interface UseWorkspaceScreenArchiveControllerInput {
serverId: string;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
workspaceDirectory: string | null | undefined;
branchLabel: string;
gitStatus: CheckoutStatusPayload | null;
t: (key: string, options?: Record) => string;
}
function resolveArchiveWorkspaceDescriptor(input: {
workspaces: Map | undefined;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
workspaceDirectory: string | null | undefined;
}): WorkspaceDescriptor | null {
const activeWorkspaceKey = input.activeWorkspaceSelection
? resolveWorkspaceMapKeyByIdentity({
workspaces: input.workspaces,
workspaceId: input.activeWorkspaceSelection.workspaceId,
})
: null;
if (activeWorkspaceKey) {
return input.workspaces?.get(activeWorkspaceKey) ?? null;
}
if (!input.workspaceDirectory) {
return null;
}
for (const candidate of input.workspaces?.values() ?? []) {
if (candidate.workspaceDirectory === input.workspaceDirectory) {
return candidate;
}
}
return null;
}
function resolveWorkspaceArchiveRisk(
workspace: WorkspaceDescriptor | null,
gitStatus: CheckoutStatusPayload | null,
): { isDirty: boolean | null | undefined; aheadOfOrigin: number | null | undefined } {
return {
isDirty: gitStatus?.isDirty ?? workspace?.gitRuntime?.isDirty,
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? workspace?.gitRuntime?.aheadOfOrigin,
};
}
function canArchiveWorkspace(
workspace: WorkspaceDescriptor | null,
risk: ReturnType,
): boolean {
return (
workspace !== null &&
(workspace.workspaceKind !== "worktree" ||
(risk.isDirty !== undefined && risk.aheadOfOrigin !== undefined))
);
}
function useWorkspaceScreenArchiveController({
serverId,
activeWorkspaceSelection,
workspaceDirectory,
branchLabel,
gitStatus,
t,
}: UseWorkspaceScreenArchiveControllerInput) {
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const workspaceDescriptor = useMemo(
() =>
resolveArchiveWorkspaceDescriptor({
workspaces: sessionWorkspaces,
activeWorkspaceSelection,
workspaceDirectory,
}),
[activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory],
);
const archiveRisk = resolveWorkspaceArchiveRisk(workspaceDescriptor, gitStatus);
const controller = useWorkspaceArchive({
serverId,
workspaceId: workspaceDescriptor?.id ?? "",
workspaceKind: workspaceDescriptor?.workspaceKind ?? "directory",
name: workspaceDescriptor?.name ?? branchLabel,
isDirty: archiveRisk.isDirty,
aheadOfOrigin: archiveRisk.aheadOfOrigin,
diffStat: workspaceDescriptor?.diffStat ?? null,
warningLabels: getWorktreeArchiveWarningLabels(t),
onSetHiding: setIsHidingWorkspace,
onArchiveStarted: () => {
if (!activeWorkspaceSelection) {
return;
}
redirectIfArchivingActiveWorkspace({
serverId,
workspaceId: activeWorkspaceSelection.workspaceId,
activeWorkspaceSelection,
});
},
});
return {
...controller,
isArchiving: workspaceDescriptor?.archivingAt != null || isHidingWorkspace,
canArchive: canArchiveWorkspace(workspaceDescriptor, archiveRisk),
};
}
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
const { t } = useTranslation();
const toast = useToast();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr");
const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error;
const baseRef = gitStatus?.baseRef ?? undefined;
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const {
status: prStatus,
githubFeaturesEnabled,
forge,
} = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
const prIcon = useMemo(() => renderForgePrIcon(forge), [forge]);
const baseRefLabel = useMemo(
() => formatBaseRefLabel(baseRef, t("workspace.git.diff.base")),
[baseRef, t],
);
const branchLabel = resolveBranchLabel({
currentBranch: gitStatus?.currentBranch,
notGit,
notRepositoryLabel: t("workspace.git.diff.notRepository"),
unknownLabel: t("workspace.git.diff.branchUnknown"),
});
// Ship default persistence
const shipDefaultStorageKey = useMemo(() => {
if (!gitStatus?.repoRoot) {
return null;
}
return `@paseo:changes-ship-default:${gitStatus.repoRoot}`;
}, [gitStatus?.repoRoot]);
useEffect(() => {
if (!shipDefaultStorageKey) {
setShipDefault("pr");
return;
}
let isActive = true;
setShipDefault("pr");
readValidatedString(AsyncStorage, shipDefaultStorageKey, z.enum(["pr", "merge"]))
.then((value) => {
if (!isActive) return;
if (value) {
setShipDefault(value);
return;
}
setShipDefault("pr");
return;
})
.catch(() => undefined);
return () => {
isActive = false;
};
}, [shipDefaultStorageKey]);
const persistShipDefault = useCallback(
async (next: "merge" | "pr") => {
setShipDefault(next);
if (!shipDefaultStorageKey) return;
try {
await AsyncStorage.setItem(shipDefaultStorageKey, next);
} catch {
// Ignore persistence failures; default will reset to "pr".
}
},
[shipDefaultStorageKey],
);
useEffect(() => {
setPostShipArchiveSuggested(false);
}, [cwd]);
const commitStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pullStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "push" }),
);
const pullAndPushStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergePrStatuses: Record = {
squash: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-squash" }),
),
merge: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-merge" }),
),
rebase: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }),
),
};
const enablePrAutoMergeStatuses: Record = {
squash: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
),
merge: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
),
rebase: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-rebase" }),
),
};
const disablePrAutoMergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
);
const mergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const runCommit = useCheckoutGitActionsStore((s) => s.commit);
const runPull = useCheckoutGitActionsStore((s) => s.pull);
const runPush = useCheckoutGitActionsStore((s) => s.push);
const runPullAndPush = useCheckoutGitActionsStore((s) => s.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr);
const runMergePr = useCheckoutGitActionsStore((s) => s.mergePr);
const runEnablePrAutoMerge = useCheckoutGitActionsStore((s) => s.enablePrAutoMerge);
const runDisablePrAutoMerge = useCheckoutGitActionsStore((s) => s.disablePrAutoMerge);
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
const githubAutoMergeActionsEnabled = useSessionStore(
(s) =>
s.sessions[serverId]?.serverInfo?.features?.checkoutForgeSetAutoMerge === true ||
s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
);
const toastActionError = useCallback(
(error: unknown, fallback: string) => {
const message = error instanceof Error ? error.message : fallback;
toast.error(message);
},
[toast],
);
const toastActionSuccess = useCallback(
(message: string) => {
toast.show(message, { variant: "success" });
},
[toast],
);
// Handlers
const handleCommit = useCallback(() => {
void runCommit({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.commit.success"));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedCommit"));
});
}, [cwd, runCommit, serverId, t, toastActionError, toastActionSuccess]);
const handlePull = useCallback(() => {
void runPull({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.pull.success"));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedPull"));
});
}, [cwd, runPull, serverId, t, toastActionError, toastActionSuccess]);
const handlePush = useCallback(() => {
void runPush({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.push.success"));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedPush"));
});
}, [cwd, runPush, serverId, t, toastActionError, toastActionSuccess]);
const handlePullAndPush = useCallback(() => {
void runPullAndPush({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.pullAndPush.success"));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedPullAndPush"));
});
}, [cwd, runPullAndPush, serverId, t, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
void runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.createPr.success", forgeVocabulary(forge)));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedCreatePr"));
});
}, [
cwd,
forge,
persistShipDefault,
runCreatePr,
serverId,
t,
toastActionError,
toastActionSuccess,
]);
const handleMergePr = useCallback(
(method: CheckoutPrMergeMethod) => {
void persistShipDefault("pr");
void runMergePr({ serverId, cwd, method })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess(t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedMergePr"));
});
},
[cwd, forge, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess],
);
const handleEnablePrAutoMerge = useCallback(
(method: CheckoutPrMergeMethod) => {
void persistShipDefault("pr");
void runEnablePrAutoMerge({ serverId, cwd, method })
.then(() => {
toastActionSuccess(t("workspace.git.actions.autoMerge.enabled"));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedEnableAutoMerge"));
});
},
[
cwd,
persistShipDefault,
runEnablePrAutoMerge,
serverId,
t,
toastActionError,
toastActionSuccess,
],
);
const handleDisablePrAutoMerge = useCallback(() => {
void runDisablePrAutoMerge({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.autoMerge.disabled"));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedDisableAutoMerge"));
});
}, [cwd, runDisablePrAutoMerge, serverId, t, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
toast.error(t("workspace.git.actions.toasts.baseRefUnavailable"));
return;
}
void persistShipDefault("merge");
void runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess(t("workspace.git.actions.mergeBranch.success"));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedMerge"));
});
}, [
baseRef,
cwd,
persistShipDefault,
runMergeBranch,
serverId,
t,
toast,
toastActionError,
toastActionSuccess,
]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
toast.error(t("workspace.git.actions.toasts.baseRefUnavailable"));
return;
}
void runMergeFromBase({ serverId, cwd, baseRef })
.then(() => {
toastActionSuccess(t("workspace.git.actions.mergeFromBase.success"));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedMergeFromBase"));
});
}, [baseRef, cwd, runMergeFromBase, serverId, t, toast, toastActionError, toastActionSuccess]);
const archiveController = useWorkspaceScreenArchiveController({
serverId,
activeWorkspaceSelection,
workspaceDirectory: status?.cwd,
branchLabel,
gitStatus,
t,
});
const handleArchiveWorkspace = useCallback(() => {
archiveController.archive();
}, [archiveController]);
const derived = deriveGitActionsState({
isGit,
status,
gitStatus,
prStatus,
hasUncommittedChanges,
postShipArchiveSuggested,
isStatusLoading,
baseRefLabel,
});
const {
actionsDisabled,
aheadCount,
behindBaseCount,
aheadOfOrigin,
behindOfOrigin,
hasPullRequest,
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
shouldPromoteArchive,
} = derived;
const handlePrAction = useCallback(() => {
if (prStatus?.url) {
openURLInNewTab(prStatus.url);
return;
}
handleCreatePr();
}, [prStatus?.url, handleCreatePr]);
// Build actions
const gitActionsInput = useMemo(() => {
const presentation = getForgePresentation(forge);
return {
isGit,
githubFeaturesEnabled,
forgeBrandLabel: presentation.brandLabel,
forgeChangeRequestNoun: presentation.changeRequestAbbrev,
githubAutoMergeActionsEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
pullRequestState: narrowPullRequestState(prStatus?.state),
pullRequestIsDraft: prStatus?.isDraft ?? false,
pullRequestIsMerged: prStatus?.isMerged ?? false,
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
mergeCapability: deriveMergeCapability(prStatus?.forgeSpecific, prStatus?.github),
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
hasUncommittedChanges,
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
aheadCount,
behindBaseCount,
aheadOfOrigin,
behindOfOrigin,
shouldPromoteArchive,
shipDefault,
runtime: {
commit: {
disabled: isActionDisabled(actionsDisabled, commitStatus),
status: commitStatus,
icon: icons.commit,
handler: handleCommit,
},
pull: {
disabled: isActionDisabled(actionsDisabled, pullStatus),
status: pullStatus,
icon: icons.pull,
handler: handlePull,
},
push: {
disabled: isActionDisabled(actionsDisabled, pushStatus),
status: pushStatus,
icon: icons.push,
handler: handlePush,
},
"pull-and-push": {
disabled: isActionDisabled(actionsDisabled, pullAndPushStatus),
status: pullAndPushStatus,
icon: icons.pullAndPush,
handler: handlePullAndPush,
},
pr: {
disabled: isActionDisabled(actionsDisabled, prCreateStatus),
status: hasPullRequest ? "idle" : prCreateStatus,
icon: prIcon,
handler: handlePrAction,
},
"merge-pr-squash": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.squash),
status: mergePrStatuses.squash,
icon: prIcon,
handler: () => handleMergePr("squash"),
},
"merge-pr-merge": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.merge),
status: mergePrStatuses.merge,
icon: prIcon,
handler: () => handleMergePr("merge"),
},
"merge-pr-rebase": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.rebase),
status: mergePrStatuses.rebase,
icon: prIcon,
handler: () => handleMergePr("rebase"),
},
"enable-pr-auto-merge-squash": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.squash),
status: enablePrAutoMergeStatuses.squash,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("squash"),
},
"enable-pr-auto-merge-merge": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.merge),
status: enablePrAutoMergeStatuses.merge,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("merge"),
},
"enable-pr-auto-merge-rebase": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.rebase),
status: enablePrAutoMergeStatuses.rebase,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("rebase"),
},
"disable-pr-auto-merge": {
disabled: isActionDisabled(actionsDisabled, disablePrAutoMergeStatus),
status: disablePrAutoMergeStatus,
icon: prIcon,
handler: handleDisablePrAutoMerge,
},
"merge-branch": {
disabled: isActionDisabled(actionsDisabled, mergeStatus),
status: mergeStatus,
icon: icons.merge,
handler: handleMergeBranch,
},
"merge-from-base": {
disabled: isActionDisabled(actionsDisabled, mergeFromBaseStatus),
status: mergeFromBaseStatus,
icon: icons.mergeFromBase,
handler: handleMergeFromBase,
},
"archive-workspace": {
disabled: !archiveController.canArchive || archiveController.isArchiving,
status: archiveController.isArchiving ? "pending" : "idle",
icon: icons.archive,
handler: handleArchiveWorkspace,
},
},
};
}, [
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
prStatus?.forgeSpecific,
prStatus?.github,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
forge,
githubAutoMergeActionsEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
actionsDisabled,
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
enablePrAutoMergeStatuses.squash,
enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.rebase,
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveController.canArchive,
archiveController.isArchiving,
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handlePrAction,
handleMergePr,
handleEnablePrAutoMerge,
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorkspace,
icons,
prIcon,
baseRef,
]);
const gitActions: GitActions = useMemo(
() =>
translateGitActions(buildGitActions(gitActionsInput), {
baseRefLabel,
hasPullRequest,
forge,
t,
}),
[gitActionsInput, baseRefLabel, hasPullRequest, forge, t],
);
return { gitActions, branchLabel, isGit };
}
function translateGitActions(
actions: GitActions,
input: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record) => string;
},
): GitActions {
return {
primary: actions.primary ? translateGitAction(actions.primary, input) : null,
secondary: actions.secondary.map((action) => translateGitAction(action, input)),
menu: actions.menu.map((action) => translateGitAction(action, input)),
};
}
function translateGitAction(
action: GitAction,
{
baseRefLabel,
hasPullRequest,
forge,
t,
}: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record) => string;
},
): GitAction {
const labels = getTranslatedGitActionLabels(action, { baseRefLabel, hasPullRequest, forge, t });
return {
...action,
...labels,
unavailableMessage: translateGitActionUnavailableMessage(action.unavailableMessage, {
baseRefLabel,
t,
}),
};
}
function getTranslatedGitActionLabels(
action: GitAction,
{
baseRefLabel,
hasPullRequest,
forge,
t,
}: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record) => string;
},
): Pick {
switch (action.id) {
case "commit":
return {
label: t("workspace.git.actions.commit.label"),
pendingLabel: t("workspace.git.actions.commit.pending"),
successLabel: t("workspace.git.actions.commit.success"),
};
case "pull":
return {
label: t("workspace.git.actions.pull.label"),
pendingLabel: t("workspace.git.actions.pull.pending"),
successLabel: t("workspace.git.actions.pull.success"),
};
case "push":
return {
label: t("workspace.git.actions.push.label"),
pendingLabel: t("workspace.git.actions.push.pending"),
successLabel: t("workspace.git.actions.push.success"),
};
case "pull-and-push":
return {
label: t("workspace.git.actions.pullAndPush.label"),
pendingLabel: t("workspace.git.actions.pullAndPush.pending"),
successLabel: t("workspace.git.actions.pullAndPush.success"),
};
case "pr":
return hasPullRequest
? {
label: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
}
: {
label: t("workspace.git.actions.createPr.label", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.createPr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.createPr.success", forgeVocabulary(forge)),
};
case "merge-pr-squash":
return {
label: t("workspace.git.actions.mergePr.squash", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "merge-pr-merge":
return {
label: t("workspace.git.actions.mergePr.merge", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "merge-pr-rebase":
return {
label: t("workspace.git.actions.mergePr.rebase", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "enable-pr-auto-merge-squash":
return {
label: t("workspace.git.actions.autoMerge.enableSquash"),
pendingLabel: t("workspace.git.actions.autoMerge.enabling"),
successLabel: t("workspace.git.actions.autoMerge.enabled"),
};
case "enable-pr-auto-merge-merge":
return {
label: t("workspace.git.actions.autoMerge.enableMerge"),
pendingLabel: t("workspace.git.actions.autoMerge.enabling"),
successLabel: t("workspace.git.actions.autoMerge.enabled"),
};
case "enable-pr-auto-merge-rebase":
return {
label: t("workspace.git.actions.autoMerge.enableRebase"),
pendingLabel: t("workspace.git.actions.autoMerge.enabling"),
successLabel: t("workspace.git.actions.autoMerge.enabled"),
};
case "disable-pr-auto-merge":
return {
label: t("workspace.git.actions.autoMerge.enabled"),
pendingLabel: t("workspace.git.actions.autoMerge.disabling"),
successLabel: t("workspace.git.actions.autoMerge.disabled"),
};
case "merge-branch":
return {
label: t("workspace.git.actions.mergeBranch.label"),
pendingLabel: t("workspace.git.actions.mergeBranch.pending"),
successLabel: t("workspace.git.actions.mergeBranch.success"),
};
case "merge-from-base":
return {
label: t("workspace.git.actions.mergeFromBase.label", { baseRef: baseRefLabel }),
pendingLabel: t("workspace.git.actions.mergeFromBase.pending"),
successLabel: t("workspace.git.actions.mergeFromBase.success"),
};
case "archive-workspace":
return {
label: t("workspace.git.actions.archive.label"),
pendingLabel: t("workspace.git.actions.archive.pending"),
successLabel: t("workspace.git.actions.archive.success"),
};
}
}
function translateGitActionUnavailableMessage(
message: string | undefined,
{
baseRefLabel,
t,
}: {
baseRefLabel: string;
t: (key: string, options?: Record) => string;
},
): string | undefined {
if (!message) return undefined;
const keyByMessage: Record = {
"Pull isn't available here because this branch is not connected to a remote yet":
"workspace.git.actions.unavailable.pullNoRemote",
"Pull isn't available while you have local changes so commit or stash them first":
"workspace.git.actions.unavailable.pullDirty",
"Pull isn't available because this branch is already up to date":
"workspace.git.actions.unavailable.pullUpToDate",
"Push isn't available here because this branch is not connected to a remote yet":
"workspace.git.actions.unavailable.pushNoRemote",
"Push isn't available yet because there are newer changes to bring in first":
"workspace.git.actions.unavailable.pushBehind",
"Push isn't available because there is nothing new to send":
"workspace.git.actions.unavailable.pushNothing",
"Pull and push isn't available here because this branch is not connected to a remote yet":
"workspace.git.actions.unavailable.pullAndPushNoRemote",
"Pull and push isn't available while you have local changes so commit or stash them first":
"workspace.git.actions.unavailable.pullAndPushDirty",
"Pull and push isn't available because this branch is already in sync":
"workspace.git.actions.unavailable.pullAndPushInSync",
"Create PR isn't available because this branch doesn't have any new commits yet":
"workspace.git.actions.unavailable.createPrNoCommits",
"Merge isn't available because we couldn't determine the base branch":
"workspace.git.actions.unavailable.mergeNoBase",
"Merge isn't available while you have local changes so commit or stash them first":
"workspace.git.actions.unavailable.mergeDirty",
"Merge isn't available because this branch doesn't have anything new to merge yet":
"workspace.git.actions.unavailable.mergeNothing",
"Update isn't available because we couldn't determine the base branch":
"workspace.git.actions.unavailable.updateNoBase",
"Update isn't available while you have local changes so commit or stash them first":
"workspace.git.actions.unavailable.updateDirty",
"Merge PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.mergePrNoGithub",
"Archive isn't available here because this workspace was not created as a Paseo worktree":
"workspace.git.actions.unavailable.archiveNotWorktree",
"Merge PR isn't available because there isn't a pull request yet":
"workspace.git.actions.unavailable.mergePrMissing",
"Merge PR isn't available because the pull request is still a draft":
"workspace.git.actions.unavailable.mergePrDraft",
"Merge PR isn't available because the pull request is already merged":
"workspace.git.actions.unavailable.mergePrMerged",
"Merge PR isn't available because the pull request is closed":
"workspace.git.actions.unavailable.mergePrClosed",
"Merge PR isn't available because the pull request has conflicts":
"workspace.git.actions.unavailable.mergePrConflicts",
"Merge PR isn't available here because this repository uses a merge queue":
"workspace.git.actions.unavailable.mergePrQueue",
"Auto-merge is enabled, but this account can't disable it":
"workspace.git.actions.unavailable.autoMergeCannotDisable",
};
if (
message.startsWith("Update isn't available because this branch is already up to date with ")
) {
return t("workspace.git.actions.unavailable.updateCurrent", { baseRef: baseRefLabel });
}
const key = keyByMessage[message];
return key ? t(key) : message;
}
function getWorktreeArchiveWarningLabels(
t: (key: string, options?: Record) => string,
): WorktreeArchiveWarningLabels {
return {
title: (workspaceName) => t("workspace.git.actions.archiveWarning.title", { workspaceName }),
confirm: t("workspace.git.actions.archiveWarning.confirm"),
cancel: t("workspace.git.actions.archiveWarning.cancel"),
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
uncommittedChangesWithDiff: (diffStat) =>
t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }),
addedLine: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.addedLine"
: "workspace.git.actions.archiveWarning.addedLines",
{ count },
),
deletedLine: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.deletedLine"
: "workspace.git.actions.archiveWarning.deletedLines",
{ count },
),
unpushedCommit: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.unpushedCommit"
: "workspace.git.actions.archiveWarning.unpushedCommits",
{ count },
),
};
}