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
@@ -79,7 +79,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" }); 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( const actions = buildGitActions(
createInput({ createInput({
hasRemote: true, hasRemote: true,
@@ -90,8 +90,8 @@ describe("git-actions-policy", () => {
const pushAction = actions.secondary.find((action) => action.id === "push"); const pushAction = actions.secondary.find((action) => action.id === "push");
expect(pushAction).toMatchObject({ expect(pushAction).toMatchObject({
disabled: true, disabled: false,
description: "Pull first", 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({ expect(updateAction).toMatchObject({
label: "Update from main", label: "Update from main",
disabled: false, 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",
}); });
}); });
@@ -18,7 +18,7 @@ export interface GitAction {
successLabel: string; successLabel: string;
disabled: boolean; disabled: boolean;
status: ActionStatus; status: ActionStatus;
description?: string; unavailableMessage?: string;
icon?: ReactElement; icon?: ReactElement;
handler: () => void; handler: () => void;
} }
@@ -82,9 +82,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: "Pull", label: "Pull",
pendingLabel: "Pulling...", pendingLabel: "Pulling...",
successLabel: "Pulled", successLabel: "Pulled",
disabled: input.runtime.pull.disabled || !canPull(input), disabled: input.runtime.pull.disabled,
status: input.runtime.pull.status, status: input.runtime.pull.status,
description: getPullDescription(input), unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
icon: input.runtime.pull.icon, icon: input.runtime.pull.icon,
handler: input.runtime.pull.handler, handler: input.runtime.pull.handler,
}); });
@@ -94,9 +94,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: "Push", label: "Push",
pendingLabel: "Pushing...", pendingLabel: "Pushing...",
successLabel: "Pushed", successLabel: "Pushed",
disabled: input.runtime.push.disabled || !canPush(input), disabled: input.runtime.push.disabled,
status: input.runtime.push.status, status: input.runtime.push.status,
description: getPushDescription(input), unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
icon: input.runtime.push.icon, icon: input.runtime.push.icon,
handler: input.runtime.push.handler, handler: input.runtime.push.handler,
}); });
@@ -108,9 +108,10 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: `Merge into ${input.baseRefLabel}`, label: `Merge into ${input.baseRefLabel}`,
pendingLabel: "Merging...", pendingLabel: "Merging...",
successLabel: "Merged", successLabel: "Merged",
disabled: input.runtime["merge-branch"].disabled || !canMergeBranch(input), disabled: input.runtime["merge-branch"].disabled,
status: input.runtime["merge-branch"].status, status: input.runtime["merge-branch"].status,
description: getMergeBranchDescription(input), unavailableMessage:
input.runtime["merge-branch"].disabled ? undefined : getMergeBranchUnavailableMessage(input),
icon: input.runtime["merge-branch"].icon, icon: input.runtime["merge-branch"].icon,
handler: input.runtime["merge-branch"].handler, handler: input.runtime["merge-branch"].handler,
}); });
@@ -120,9 +121,12 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: `Update from ${input.baseRefLabel}`, label: `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...", pendingLabel: "Updating...",
successLabel: "Updated", 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, 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, icon: input.runtime["merge-from-base"].icon,
handler: input.runtime["merge-from-base"].handler, handler: input.runtime["merge-from-base"].handler,
}); });
@@ -132,9 +136,12 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: "Archive worktree", label: "Archive worktree",
pendingLabel: "Archiving...", pendingLabel: "Archiving...",
successLabel: "Archived", successLabel: "Archived",
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree, disabled: input.runtime["archive-worktree"].disabled,
status: input.runtime["archive-worktree"].status, 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, icon: input.runtime["archive-worktree"].icon,
handler: input.runtime["archive-worktree"].handler, handler: input.runtime["archive-worktree"].handler,
}); });
@@ -189,9 +196,12 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "View PR", label: "View PR",
pendingLabel: "View PR", pendingLabel: "View PR",
successLabel: "View PR", successLabel: "View PR",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled, disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status, 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, icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler, handler: input.runtime.pr.handler,
}; };
@@ -202,9 +212,10 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "Create PR", label: "Create PR",
pendingLabel: "Creating PR...", pendingLabel: "Creating PR...",
successLabel: "PR Created", successLabel: "PR Created",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0, disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status, status: input.runtime.pr.status,
description: getCreatePrDescription(input), unavailableMessage:
input.runtime.pr.disabled ? undefined : getCreatePrUnavailableMessage(input),
icon: input.runtime.pr.icon, icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler, 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) { 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) { 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) { if (input.behindOfOrigin === 0) {
return "Nothing to pull"; return "Pull isn't available because this branch is already up to date";
} }
return undefined; return undefined;
} }
function getPushDescription(input: BuildGitActionsInput): string | undefined { function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) { 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) { 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) { if (input.aheadOfOrigin === 0) {
return "Nothing to push"; return "Push isn't available because there is nothing new to send";
} }
return undefined; return undefined;
} }
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined { function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) { if (!input.githubFeaturesEnabled) {
return "GitHub unavailable"; return "Create PR isn't available right now because GitHub isn't connected";
} }
if (input.aheadCount === 0) { 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; return undefined;
} }
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined { function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) { if (!input.baseRefAvailable) {
return "No base"; return "Merge isn't available because we couldn't determine the base branch";
} }
if (input.hasUncommittedChanges) { 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) { 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; return undefined;
} }
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined { function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) { if (!input.baseRefAvailable) {
return "No base"; return "Update isn't available because we couldn't determine the base branch";
} }
if (input.hasUncommittedChanges) { 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) { 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; return undefined;
} }
@@ -1,7 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { View, Text, ActivityIndicator, Pressable } from "react-native"; import { View, Text, ActivityIndicator, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, MoreVertical } from "lucide-react-native"; import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/components/git-actions-policy"; import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps { interface GitActionsSplitButtonProps {
@@ -19,6 +20,7 @@ interface GitActionsSplitButtonProps {
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) { export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast();
const archiveShortcutKeys = useShortcutKeys("archive-worktree"); const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const getActionDisplayLabel = useCallback((action: GitAction): string => { const getActionDisplayLabel = useCallback((action: GitAction): string => {
@@ -27,6 +29,20 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
return action.label; 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 ( return (
<View style={styles.row}> <View style={styles.row}>
{gitActions.primary ? ( {gitActions.primary ? (
@@ -87,6 +103,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
: undefined : undefined
} }
disabled={action.disabled} disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status} status={action.status}
pendingLabel={action.pendingLabel} pendingLabel={action.pendingLabel}
successLabel={action.successLabel} successLabel={action.successLabel}
@@ -95,8 +112,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
action.id === "pr" && action.id === "pr" &&
action.label === "View PR" action.label === "View PR"
} }
description={action.description} onSelect={() => handleActionSelect(action)}
onSelect={action.handler}
> >
{action.label} {action.label}
</DropdownMenuItem> </DropdownMenuItem>
@@ -126,11 +142,12 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
testID={`changes-menu-${action.id}`} testID={`changes-menu-${action.id}`}
leading={action.icon} leading={action.icon}
disabled={action.disabled} disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status} status={action.status}
pendingLabel={action.pendingLabel} pendingLabel={action.pendingLabel}
successLabel={action.successLabel} successLabel={action.successLabel}
closeOnSelect={false} closeOnSelect={false}
onSelect={action.handler} onSelect={() => handleActionSelect(action)}
> >
{action.label} {action.label}
</DropdownMenuItem> </DropdownMenuItem>
@@ -460,6 +460,7 @@ export function DropdownMenuItem({
description, description,
onSelect, onSelect,
disabled, disabled,
muted = false,
destructive, destructive,
selected, selected,
showSelectedCheck = false, showSelectedCheck = false,
@@ -477,6 +478,7 @@ export function DropdownMenuItem({
description?: string; description?: string;
onSelect?: () => void; onSelect?: () => void;
disabled?: boolean; disabled?: boolean;
muted?: boolean;
destructive?: boolean; destructive?: boolean;
selected?: boolean; selected?: boolean;
showSelectedCheck?: boolean; showSelectedCheck?: boolean;
@@ -550,6 +552,7 @@ export function DropdownMenuItem({
? styles.itemSelectedInteractive ? styles.itemSelectedInteractive
: null, : null,
isDisabled ? styles.itemDisabled : null, isDisabled ? styles.itemDisabled : null,
muted && !isDisabled ? styles.itemMuted : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null, hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null, pressed && !isDisabled ? styles.itemPressed : null,
]} ]}
@@ -568,6 +571,7 @@ export function DropdownMenuItem({
destructive && !isSuccess ? styles.itemTextDestructive : null, destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null, isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null, selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
muted && !isDisabled ? styles.itemTextMuted : null,
]} ]}
> >
{label} {label}
@@ -678,11 +682,17 @@ const styles = StyleSheet.create((theme) => ({
itemDisabled: { itemDisabled: {
opacity: 0.5, opacity: 0.5,
}, },
itemMuted: {
opacity: 0.72,
},
itemText: { itemText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.foreground, color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
itemTextMuted: {
color: theme.colors.foregroundMuted,
},
itemTextDestructive: { itemTextDestructive: {
color: theme.colors.destructive, color: theme.colors.destructive,
}, },