Replace git action disables with unavailability toasts

This commit is contained in:
Mohamed Boudra
2026-04-10 20:57:51 +07:00
parent 70985088f6
commit 7a1e12f0c0
4 changed files with 90 additions and 41 deletions

View File

@@ -79,7 +79,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
});
it("disables push with a short pull-first message when the branch diverged", () => {
it("keeps push clickable with a clearer message when the branch diverged", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
@@ -90,8 +90,8 @@ describe("git-actions-policy", () => {
const pushAction = actions.secondary.find((action) => action.id === "push");
expect(pushAction).toMatchObject({
disabled: true,
description: "Pull first",
disabled: false,
unavailableMessage: "Push isn't available yet because there are newer changes to bring in first",
});
});
@@ -108,6 +108,17 @@ describe("git-actions-policy", () => {
expect(updateAction).toMatchObject({
label: "Update from main",
disabled: false,
unavailableMessage: undefined,
});
});
it("uses a clear sentence when pull is unavailable", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const pullAction = actions.secondary.find((action) => action.id === "pull");
expect(pullAction).toMatchObject({
disabled: false,
unavailableMessage: "Pull isn't available because this branch is already up to date",
});
});

View File

@@ -18,7 +18,7 @@ export interface GitAction {
successLabel: string;
disabled: boolean;
status: ActionStatus;
description?: string;
unavailableMessage?: string;
icon?: ReactElement;
handler: () => void;
}
@@ -82,9 +82,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: "Pull",
pendingLabel: "Pulling...",
successLabel: "Pulled",
disabled: input.runtime.pull.disabled || !canPull(input),
disabled: input.runtime.pull.disabled,
status: input.runtime.pull.status,
description: getPullDescription(input),
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
icon: input.runtime.pull.icon,
handler: input.runtime.pull.handler,
});
@@ -94,9 +94,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: input.runtime.push.disabled || !canPush(input),
disabled: input.runtime.push.disabled,
status: input.runtime.push.status,
description: getPushDescription(input),
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
icon: input.runtime.push.icon,
handler: input.runtime.push.handler,
});
@@ -108,9 +108,10 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: `Merge into ${input.baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: input.runtime["merge-branch"].disabled || !canMergeBranch(input),
disabled: input.runtime["merge-branch"].disabled,
status: input.runtime["merge-branch"].status,
description: getMergeBranchDescription(input),
unavailableMessage:
input.runtime["merge-branch"].disabled ? undefined : getMergeBranchUnavailableMessage(input),
icon: input.runtime["merge-branch"].icon,
handler: input.runtime["merge-branch"].handler,
});
@@ -120,9 +121,12 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
disabled: input.runtime["merge-from-base"].disabled,
status: input.runtime["merge-from-base"].status,
description: getMergeFromBaseDescription(input),
unavailableMessage:
input.runtime["merge-from-base"].disabled
? undefined
: getMergeFromBaseUnavailableMessage(input),
icon: input.runtime["merge-from-base"].icon,
handler: input.runtime["merge-from-base"].handler,
});
@@ -132,9 +136,12 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
disabled: input.runtime["archive-worktree"].disabled,
status: input.runtime["archive-worktree"].status,
description: input.isPaseoOwnedWorktree ? undefined : "Only for worktrees",
unavailableMessage:
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
? undefined
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
icon: input.runtime["archive-worktree"].icon,
handler: input.runtime["archive-worktree"].handler,
});
@@ -189,9 +196,12 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status,
description: input.githubFeaturesEnabled ? undefined : "GitHub unavailable",
unavailableMessage:
input.runtime.pr.disabled || input.githubFeaturesEnabled
? undefined
: "View PR isn't available right now because GitHub isn't connected",
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
@@ -202,9 +212,10 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status,
description: getCreatePrDescription(input),
unavailableMessage:
input.runtime.pr.disabled ? undefined : getCreatePrUnavailableMessage(input),
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
@@ -240,64 +251,64 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean {
);
}
function getPullDescription(input: BuildGitActionsInput): string | undefined {
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "No remote";
return "Pull isn't available here because this branch is not connected to a remote yet";
}
if (input.hasUncommittedChanges) {
return "Clean tree";
return "Pull isn't available while you have local changes so commit or stash them first";
}
if (input.behindOfOrigin === 0) {
return "Nothing to pull";
return "Pull isn't available because this branch is already up to date";
}
return undefined;
}
function getPushDescription(input: BuildGitActionsInput): string | undefined {
function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "No remote";
return "Push isn't available here because this branch is not connected to a remote yet";
}
if (input.behindOfOrigin > 0) {
return "Pull first";
return "Push isn't available yet because there are newer changes to bring in first";
}
if (input.aheadOfOrigin === 0) {
return "Nothing to push";
return "Push isn't available because there is nothing new to send";
}
return undefined;
}
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "GitHub unavailable";
return "Create PR isn't available right now because GitHub isn't connected";
}
if (input.aheadCount === 0) {
return "No new commits";
return "Create PR isn't available because this branch doesn't have any new commits yet";
}
return undefined;
}
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "No base";
return "Merge isn't available because we couldn't determine the base branch";
}
if (input.hasUncommittedChanges) {
return "Clean tree";
return "Merge isn't available while you have local changes so commit or stash them first";
}
if (input.aheadCount === 0) {
return "No new commits";
return "Merge isn't available because this branch doesn't have anything new to merge yet";
}
return undefined;
}
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "No base";
return "Update isn't available because we couldn't determine the base branch";
}
if (input.hasUncommittedChanges) {
return "Clean tree";
return "Update isn't available while you have local changes so commit or stash them first";
}
if (input.behindBaseCount === 0) {
return "Up to date";
return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
}
return undefined;
}

View File

@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { View, Text, ActivityIndicator, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, MoreVertical } from "lucide-react-native";
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
import {
DropdownMenu,
DropdownMenuContent,
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps {
@@ -19,6 +20,7 @@ interface GitActionsSplitButtonProps {
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const getActionDisplayLabel = useCallback((action: GitAction): string => {
@@ -27,6 +29,20 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
return action.label;
}, []);
const handleActionSelect = useCallback(
(action: GitAction) => {
if (action.unavailableMessage) {
toast.show(action.unavailableMessage, {
durationMs: 3200,
icon: <Info size={16} color={theme.colors.foreground} />,
});
return;
}
action.handler();
},
[theme.colors.foreground, toast],
);
return (
<View style={styles.row}>
{gitActions.primary ? (
@@ -87,6 +103,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
: undefined
}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
@@ -95,8 +112,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
action.id === "pr" &&
action.label === "View PR"
}
description={action.description}
onSelect={action.handler}
onSelect={() => handleActionSelect(action)}
>
{action.label}
</DropdownMenuItem>
@@ -126,11 +142,12 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
testID={`changes-menu-${action.id}`}
leading={action.icon}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={false}
onSelect={action.handler}
onSelect={() => handleActionSelect(action)}
>
{action.label}
</DropdownMenuItem>

View File

@@ -460,6 +460,7 @@ export function DropdownMenuItem({
description,
onSelect,
disabled,
muted = false,
destructive,
selected,
showSelectedCheck = false,
@@ -477,6 +478,7 @@ export function DropdownMenuItem({
description?: string;
onSelect?: () => void;
disabled?: boolean;
muted?: boolean;
destructive?: boolean;
selected?: boolean;
showSelectedCheck?: boolean;
@@ -550,6 +552,7 @@ export function DropdownMenuItem({
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
muted && !isDisabled ? styles.itemMuted : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
]}
@@ -568,6 +571,7 @@ export function DropdownMenuItem({
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
muted && !isDisabled ? styles.itemTextMuted : null,
]}
>
{label}
@@ -678,11 +682,17 @@ const styles = StyleSheet.create((theme) => ({
itemDisabled: {
opacity: 0.5,
},
itemMuted: {
opacity: 0.72,
},
itemText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
itemTextMuted: {
color: theme.colors.foregroundMuted,
},
itemTextDestructive: {
color: theme.colors.destructive,
},