refactor: restructure git actions into data-driven design pattern

This commit is contained in:
Mohamed Boudra
2026-02-03 10:13:40 +07:00
parent 0804f51b03
commit 9cc953625f
2 changed files with 251 additions and 229 deletions
@@ -1261,7 +1261,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
}, },
optionsContainerDesktop: { optionsContainerDesktop: {
flexDirection: "row", flexDirection: "row",
justifyContent: "flex-end", justifyContent: "flex-start",
alignItems: "center", alignItems: "center",
width: "100%", width: "100%",
}, },
+222 -200
View File
@@ -54,6 +54,37 @@ type ActionState = {
trigger: () => void; trigger: () => void;
}; };
// =============================================================================
// Git Actions Data Structure
// =============================================================================
type GitActionId =
| "commit"
| "push"
| "view-pr"
| "create-pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
interface GitAction {
id: GitActionId;
label: string;
pendingLabel: string;
successLabel: string;
disabled: boolean;
status: ActionStatus;
description?: string;
destructive?: boolean;
handler: () => void;
}
interface GitActions {
primary: GitAction | null;
secondary: GitAction[];
menu: GitAction[];
}
function useActionStatus<TData, TError, TVariables, TContext>( function useActionStatus<TData, TError, TVariables, TContext>(
mutation: UseMutationResult<TData, TError, TVariables, TContext>, mutation: UseMutationResult<TData, TError, TVariables, TContext>,
onTrigger?: () => void onTrigger?: () => void
@@ -843,151 +874,177 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
} }
const hasPullRequest = Boolean(prStatus?.url); const hasPullRequest = Boolean(prStatus?.url);
const hasRemote = gitStatus?.hasRemote ?? false;
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
// ========================================================================== // ==========================================================================
// Primary CTA Logic // Git Actions (Data-Oriented)
// ========================================================================== // ==========================================================================
// Rules (in priority order): // All possible actions are computed as data, then partitioned into:
// 1. Uncommitted changes → "Commit" is primary // - primary: The main CTA button
// 2. Ahead of origin (unpushed commits) → "Push" is primary // - secondary: Dropdown next to primary button
// 3. Has PR → "View PR" is primary // - menu: Kebab overflow menu
// 4. Ahead of base → "Merge branch" or "Create PR" based on shipDefault preference
// 5. Nothing to do → no primary CTA
// ========================================================================== // ==========================================================================
type PrimaryCTA = const gitActions: GitActions = useMemo(() => {
| { type: "commit" } if (!isGit) {
| { type: "push" } return { primary: null, secondary: [], menu: [] };
| { type: "view-pr"; url: string } }
| { type: "ship"; action: "merge" | "create-pr" }
| null;
const primaryCTA: PrimaryCTA = useMemo(() => { // Build all possible actions
if (!isGit) return null; const allActions = new Map<GitActionId, GitAction>();
// Commit - always available
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: commitDisabled,
status: commitAction.status,
handler: commitAction.trigger,
});
// Push - when has remote
if (hasRemote) {
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: pushDisabled,
status: pushAction.status,
description: !hasRemote ? "No remote configured" : undefined,
handler: pushAction.trigger,
});
}
// View PR - when PR exists
if (hasPullRequest && prStatus?.url) {
const prUrl = prStatus.url;
allActions.set("view-pr", {
id: "view-pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: false,
status: "idle",
handler: () => openURLInNewTab(prUrl),
});
}
// Create PR - when ahead of base and no PR
if (aheadCount > 0 && !hasPullRequest) {
allActions.set("create-pr", {
id: "create-pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: prDisabled,
status: prCreateAction.status,
handler: prCreateAction.trigger,
});
}
// Merge branch - when ahead of base
if (aheadCount > 0) {
allActions.set("merge-branch", {
id: "merge-branch",
label: "Merge branch",
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeDisabled,
status: mergeAction.status,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
handler: mergeAction.trigger,
});
}
// Merge from base - always available
allActions.set("merge-from-base", {
id: "merge-from-base",
label: `Merge from ${baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseAction.status,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
handler: mergeFromBaseAction.trigger,
});
// Archive worktree - only for Paseo worktrees
if (isPaseoOwnedWorktree) {
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: archiveDisabled,
status: archiveAction.status,
destructive: true,
handler: archiveAction.trigger,
});
}
// Select primary action (priority rules)
let primaryActionId: GitActionId | null = null;
// Rule 1: Uncommitted changes → Commit // Rule 1: Uncommitted changes → Commit
if (hasUncommittedChanges) { if (hasUncommittedChanges) {
return { type: "commit" }; primaryActionId = "commit";
} }
// Rule 2: Ahead of origin → Push // Rule 2: Ahead of origin → Push
if (aheadOfOrigin > 0 && !pushDisabled) { else if (aheadOfOrigin > 0 && allActions.has("push") && !pushDisabled) {
return { type: "push" }; primaryActionId = "push";
} }
// Rule 3: Has PR → View PR // Rule 3: Has PR → View PR
if (hasPullRequest && prStatus?.url) { else if (hasPullRequest) {
return { type: "view-pr", url: prStatus.url }; primaryActionId = "view-pr";
}
// Rule 4: Ahead of base → Ship action based on preference
else if (aheadCount > 0) {
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
const preferredAction = allActions.get(preferred);
const fallbackAction = allActions.get(fallback);
if (preferredAction && !preferredAction.disabled) {
primaryActionId = preferred;
} else if (fallbackAction && !fallbackAction.disabled) {
primaryActionId = fallback;
} else if (preferredAction) {
primaryActionId = preferred;
}
} }
// Rule 4: Ahead of base → Ship (merge or create PR based on preference) const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
if (aheadCount > 0) {
const preferredAction = shipDefault === "merge" ? "merge" : "create-pr";
// If preferred action is disabled, fall back to the other
if (preferredAction === "merge" && mergeDisabled && !prDisabled) {
return { type: "ship", action: "create-pr" };
}
if (preferredAction === "create-pr" && prDisabled && !mergeDisabled) {
return { type: "ship", action: "merge" };
}
return { type: "ship", action: preferredAction };
}
// Rule 5: Nothing to do // Secondary actions: ship-related + merge from base + push (excluding primary)
return null; const secondaryIds: GitActionId[] = ["merge-branch", "create-pr", "view-pr", "merge-from-base", "push"];
}, [isGit, hasUncommittedChanges, aheadOfOrigin, pushDisabled, hasPullRequest, prStatus?.url, aheadCount, shipDefault, mergeDisabled, prDisabled]); const secondary = secondaryIds
.filter(id => id !== primaryActionId && allActions.has(id))
.map(id => allActions.get(id)!);
const primaryCTALabel = useMemo(() => { // Menu actions: archive worktree only
if (!primaryCTA) return ""; const menu = allActions.has("archive-worktree")
switch (primaryCTA.type) { ? [allActions.get("archive-worktree")!]
case "commit": : [];
return "Commit";
case "push":
return "Push";
case "view-pr":
return "View PR";
case "ship":
return primaryCTA.action === "merge" ? "Merge branch" : "Create PR";
}
}, [primaryCTA]);
const primaryCTADisabled = useMemo(() => { return { primary, secondary, menu };
if (!primaryCTA || actionsDisabled) return true; }, [
switch (primaryCTA.type) { isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree,
case "commit": hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
return commitDisabled; commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
case "push": commitAction, pushAction, prCreateAction, mergeAction, mergeFromBaseAction, archiveAction,
return pushDisabled; ]);
case "view-pr":
return false; // View PR is never disabled
case "ship":
return primaryCTA.action === "merge" ? mergeDisabled : prDisabled;
}
}, [primaryCTA, actionsDisabled, commitDisabled, pushDisabled, mergeDisabled, prDisabled]);
const primaryCTAStatus: ActionStatus = useMemo(() => { // Helper to get display label based on status
if (!primaryCTA) return "idle"; const getActionDisplayLabel = useCallback((action: GitAction): string => {
switch (primaryCTA.type) { if (action.status === "pending") return action.pendingLabel;
case "commit": if (action.status === "success") return action.successLabel;
return commitAction.status; return action.label;
case "push": }, []);
return pushAction.status;
case "view-pr":
return "idle"; // View PR is instant, no status
case "ship":
return primaryCTA.action === "merge" ? mergeAction.status : prCreateAction.status;
}
}, [primaryCTA, commitAction.status, pushAction.status, mergeAction.status, prCreateAction.status]);
const primaryCTADisplayLabel = useMemo(() => {
if (!primaryCTA) return "";
const status = primaryCTAStatus;
switch (primaryCTA.type) {
case "commit":
if (status === "pending") return "Committing...";
if (status === "success") return "Committed";
return "Commit";
case "push":
if (status === "pending") return "Pushing...";
if (status === "success") return "Pushed";
return "Push";
case "view-pr":
return "View PR";
case "ship":
if (primaryCTA.action === "merge") {
if (status === "pending") return "Merging...";
if (status === "success") return "Merged";
return "Merge branch";
} else {
if (status === "pending") return "Creating PR...";
if (status === "success") return "PR Created";
return "Create PR";
}
}
}, [primaryCTA, primaryCTAStatus]);
const handlePrimaryCTAPress = useCallback(() => {
if (!primaryCTA || primaryCTADisabled || primaryCTAStatus !== "idle") return;
switch (primaryCTA.type) {
case "commit":
commitAction.trigger();
break;
case "push":
pushAction.trigger();
break;
case "view-pr":
openURLInNewTab(primaryCTA.url);
break;
case "ship":
if (primaryCTA.action === "merge") {
mergeAction.trigger();
} else {
prCreateAction.trigger();
}
break;
}
}, [primaryCTA, primaryCTADisabled, primaryCTAStatus, commitAction, pushAction, mergeAction, prCreateAction]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -1003,25 +1060,26 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
</View> </View>
{isGit ? ( {isGit ? (
<View style={styles.headerRight}> <View style={styles.headerRight}>
{primaryCTA ? ( {gitActions.primary ? (
<View style={styles.splitButton}> <View style={styles.splitButton}>
<Pressable <Pressable
testID="changes-primary-cta" testID="changes-primary-cta"
style={[ style={[
styles.splitButtonPrimary, styles.splitButtonPrimary,
primaryCTADisabled && styles.splitButtonPrimaryDisabled, gitActions.primary.disabled && styles.splitButtonPrimaryDisabled,
]} ]}
onPress={handlePrimaryCTAPress} onPress={gitActions.primary.handler}
disabled={primaryCTADisabled} disabled={gitActions.primary.disabled}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={primaryCTALabel} accessibilityLabel={gitActions.primary.label}
> >
{primaryCTAStatus === "pending" ? ( {gitActions.primary.status === "pending" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} style={styles.splitButtonSpinner} /> <ActivityIndicator size="small" color={theme.colors.foreground} style={styles.splitButtonSpinner} />
) : ( ) : (
<Text style={styles.splitButtonText}>{primaryCTADisplayLabel}</Text> <Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
)} )}
</Pressable> </Pressable>
{gitActions.secondary.length > 0 ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
testID="changes-primary-cta-caret" testID="changes-primary-cta-caret"
@@ -1032,70 +1090,32 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
<ChevronDown size={16} color={theme.colors.foregroundMuted} /> <ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} testID="changes-primary-cta-menu"> <DropdownMenuContent align="end" width={260} testID="changes-primary-cta-menu">
{/* Ship actions - only show if ahead of base */} {gitActions.secondary.map((action, index) => {
{aheadCount > 0 ? ( const needsSeparator = action.id === "merge-from-base" || action.id === "push";
<> return (
<View key={action.id}>
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem <DropdownMenuItem
testID="changes-menu-merge" testID={`changes-menu-${action.id}`}
disabled={mergeDisabled} disabled={action.disabled}
status={mergeAction.status} status={action.status}
pendingLabel="Merging..." pendingLabel={action.pendingLabel}
successLabel="Merged" successLabel={action.successLabel}
closeOnSelect={false} closeOnSelect={action.status === "idle" && action.id === "view-pr"}
description={hasUncommittedChanges ? "Requires clean working tree" : undefined} description={action.description}
onSelect={mergeAction.trigger} onSelect={action.handler}
> >
Merge branch {action.label}
</DropdownMenuItem>
<DropdownMenuItem
testID={hasPullRequest ? "changes-menu-view-pr" : "changes-menu-create-pr"}
disabled={!hasPullRequest && prDisabled}
status={!hasPullRequest ? prCreateAction.status : undefined}
pendingLabel="Creating PR..."
successLabel="PR Created"
closeOnSelect={!hasPullRequest ? false : true}
onSelect={() => {
if (hasPullRequest && prStatus?.url) {
openURLInNewTab(prStatus.url);
return;
}
prCreateAction.trigger();
}}
>
{hasPullRequest ? "View PR" : "Create PR"}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem
testID="changes-menu-merge-from-base"
disabled={mergeFromBaseDisabled}
status={mergeFromBaseAction.status}
pendingLabel="Merging..."
successLabel="Merged"
closeOnSelect={false}
description={hasUncommittedChanges ? "Requires clean working tree" : undefined}
onSelect={mergeFromBaseAction.trigger}
>
Merge from {baseRefLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-menu-push"
disabled={pushDisabled}
status={pushAction.status}
pendingLabel="Pushing..."
successLabel="Pushed"
closeOnSelect={false}
description={!(gitStatus?.hasRemote ?? false) ? "No remote configured" : undefined}
onSelect={pushAction.trigger}
>
Push to remote
</DropdownMenuItem> </DropdownMenuItem>
</View>
);
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null}
</View> </View>
) : null} ) : null}
{gitActions.menu.length > 0 ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
testID="changes-overflow-menu" testID="changes-overflow-menu"
@@ -1107,22 +1127,24 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
<MoreVertical size={16} color={theme.colors.foregroundMuted} /> <MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content"> <DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
{gitStatus?.isPaseoOwnedWorktree ? ( {gitActions.menu.map((action) => (
<DropdownMenuItem <DropdownMenuItem
testID="changes-menu-archive" key={action.id}
destructive testID={`changes-menu-${action.id}`}
disabled={archiveDisabled} destructive={action.destructive}
status={archiveAction.status} disabled={action.disabled}
pendingLabel="Archiving..." status={action.status}
successLabel="Archived" pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={false} closeOnSelect={false}
onSelect={archiveAction.trigger} onSelect={action.handler}
> >
Archive worktree {action.label}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null}
</View> </View>
) : null} ) : null}
</View> </View>