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%",
}, },
+250 -228
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";
// Rule 4: Ahead of base → Ship (merge or create PR based on preference) const preferredAction = allActions.get(preferred);
if (aheadCount > 0) { const fallbackAction = allActions.get(fallback);
const preferredAction = shipDefault === "merge" ? "merge" : "create-pr";
// If preferred action is disabled, fall back to the other if (preferredAction && !preferredAction.disabled) {
if (preferredAction === "merge" && mergeDisabled && !prDisabled) { primaryActionId = preferred;
return { type: "ship", action: "create-pr" }; } else if (fallbackAction && !fallbackAction.disabled) {
primaryActionId = fallback;
} else if (preferredAction) {
primaryActionId = preferred;
} }
if (preferredAction === "create-pr" && prDisabled && !mergeDisabled) {
return { type: "ship", action: "merge" };
}
return { type: "ship", action: preferredAction };
} }
// Rule 5: Nothing to do const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
return null;
}, [isGit, hasUncommittedChanges, aheadOfOrigin, pushDisabled, hasPullRequest, prStatus?.url, aheadCount, shipDefault, mergeDisabled, prDisabled]);
const primaryCTALabel = useMemo(() => { // Secondary actions: ship-related + merge from base + push (excluding primary)
if (!primaryCTA) return ""; const secondaryIds: GitActionId[] = ["merge-branch", "create-pr", "view-pr", "merge-from-base", "push"];
switch (primaryCTA.type) { const secondary = secondaryIds
case "commit": .filter(id => id !== primaryActionId && allActions.has(id))
return "Commit"; .map(id => allActions.get(id)!);
case "push":
return "Push";
case "view-pr":
return "View PR";
case "ship":
return primaryCTA.action === "merge" ? "Merge branch" : "Create PR";
}
}, [primaryCTA]);
const primaryCTADisabled = useMemo(() => { // Menu actions: archive worktree only
if (!primaryCTA || actionsDisabled) return true; const menu = allActions.has("archive-worktree")
switch (primaryCTA.type) { ? [allActions.get("archive-worktree")!]
case "commit": : [];
return commitDisabled;
case "push":
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(() => { return { primary, secondary, menu };
if (!primaryCTA) return "idle"; }, [
switch (primaryCTA.type) { isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree,
case "commit": hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
return commitAction.status; commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
case "push": commitAction, pushAction, prCreateAction, mergeAction, mergeFromBaseAction, archiveAction,
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(() => { // Helper to get display label based on status
if (!primaryCTA) return ""; const getActionDisplayLabel = useCallback((action: GitAction): string => {
const status = primaryCTAStatus; if (action.status === "pending") return action.pendingLabel;
if (action.status === "success") return action.successLabel;
switch (primaryCTA.type) { return action.label;
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,126 +1060,91 @@ 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>
<DropdownMenu> {gitActions.secondary.length > 0 ? (
<DropdownMenuTrigger <DropdownMenu>
testID="changes-primary-cta-caret" <DropdownMenuTrigger
style={styles.splitButtonCaret} testID="changes-primary-cta-caret"
accessibilityRole="button" style={styles.splitButtonCaret}
accessibilityLabel="More options" accessibilityRole="button"
> accessibilityLabel="More options"
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} testID="changes-primary-cta-menu">
{/* Ship actions - only show if ahead of base */}
{aheadCount > 0 ? (
<>
<DropdownMenuItem
testID="changes-menu-merge"
disabled={mergeDisabled}
status={mergeAction.status}
pendingLabel="Merging..."
successLabel="Merged"
closeOnSelect={false}
description={hasUncommittedChanges ? "Requires clean working tree" : undefined}
onSelect={mergeAction.trigger}
>
Merge branch
</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} <ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuItem> </DropdownMenuTrigger>
<DropdownMenuSeparator /> <DropdownMenuContent align="end" width={260} testID="changes-primary-cta-menu">
<DropdownMenuItem {gitActions.secondary.map((action, index) => {
testID="changes-menu-push" const needsSeparator = action.id === "merge-from-base" || action.id === "push";
disabled={pushDisabled} return (
status={pushAction.status} <View key={action.id}>
pendingLabel="Pushing..." {needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
successLabel="Pushed" <DropdownMenuItem
closeOnSelect={false} testID={`changes-menu-${action.id}`}
description={!(gitStatus?.hasRemote ?? false) ? "No remote configured" : undefined} disabled={action.disabled}
onSelect={pushAction.trigger} status={action.status}
> pendingLabel={action.pendingLabel}
Push to remote successLabel={action.successLabel}
</DropdownMenuItem> closeOnSelect={action.status === "idle" && action.id === "view-pr"}
</DropdownMenuContent> description={action.description}
</DropdownMenu> onSelect={action.handler}
>
{action.label}
</DropdownMenuItem>
</View>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View> </View>
) : null} ) : null}
<DropdownMenu> {gitActions.menu.length > 0 ? (
<DropdownMenuTrigger <DropdownMenu>
testID="changes-overflow-menu" <DropdownMenuTrigger
hitSlop={8} testID="changes-overflow-menu"
style={styles.iconButton} hitSlop={8}
accessibilityRole="button" style={styles.iconButton}
accessibilityLabel="More actions" accessibilityRole="button"
> accessibilityLabel="More actions"
<MoreVertical size={16} color={theme.colors.foregroundMuted} /> >
</DropdownMenuTrigger> <MoreVertical size={16} color={theme.colors.foregroundMuted} />
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content"> </DropdownMenuTrigger>
{gitStatus?.isPaseoOwnedWorktree ? ( <DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
<DropdownMenuItem {gitActions.menu.map((action) => (
testID="changes-menu-archive" <DropdownMenuItem
destructive key={action.id}
disabled={archiveDisabled} testID={`changes-menu-${action.id}`}
status={archiveAction.status} destructive={action.destructive}
pendingLabel="Archiving..." disabled={action.disabled}
successLabel="Archived" status={action.status}
closeOnSelect={false} pendingLabel={action.pendingLabel}
onSelect={archiveAction.trigger} successLabel={action.successLabel}
> closeOnSelect={false}
Archive worktree onSelect={action.handler}
</DropdownMenuItem> >
) : null} {action.label}
</DropdownMenuContent> </DropdownMenuItem>
</DropdownMenu> ))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View> </View>
) : null} ) : null}
</View> </View>