From 7a1e12f0c0a550f8662077adf4441dc06a577044 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 20:57:51 +0700 Subject: [PATCH] Replace git action disables with unavailability toasts --- .../src/components/git-actions-policy.test.ts | 17 +++- .../app/src/components/git-actions-policy.ts | 79 +++++++++++-------- .../components/git-actions-split-button.tsx | 25 +++++- .../app/src/components/ui/dropdown-menu.tsx | 10 +++ 4 files changed, 90 insertions(+), 41 deletions(-) diff --git a/packages/app/src/components/git-actions-policy.test.ts b/packages/app/src/components/git-actions-policy.test.ts index 1ee48e87f..cd02f515a 100644 --- a/packages/app/src/components/git-actions-policy.test.ts +++ b/packages/app/src/components/git-actions-policy.test.ts @@ -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", }); }); diff --git a/packages/app/src/components/git-actions-policy.ts b/packages/app/src/components/git-actions-policy.ts index 4220e8f96..df1c9e7a9 100644 --- a/packages/app/src/components/git-actions-policy.ts +++ b/packages/app/src/components/git-actions-policy.ts @@ -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; } diff --git a/packages/app/src/components/git-actions-split-button.tsx b/packages/app/src/components/git-actions-split-button.tsx index f0a6bd802..7a5dffe12 100644 --- a/packages/app/src/components/git-actions-split-button.tsx +++ b/packages/app/src/components/git-actions-split-button.tsx @@ -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: , + }); + return; + } + action.handler(); + }, + [theme.colors.foreground, toast], + ); + return ( {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} @@ -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} diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 2441b14d4..405d6714b 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -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, },