From cf7cde886545ba8b088fdcb3443fa2e044bf58e7 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Fri, 26 Jun 2026 13:36:44 -0700 Subject: [PATCH] Align mobile review quick action with desktop Co-authored-by: Orca --- .../MobileSourceControlContent.tsx | 16 +- ...-source-control-primary-action-decision.ts | 268 +++++++++++ ...bile-source-control-primary-action.test.ts | 254 ++++++++++ .../mobile-source-control-primary-action.ts | 241 ++++++++++ .../use-mobile-source-control-state.ts | 43 ++ mobile/vitest.config.ts | 28 ++ .../source-control-create-pr-intent-state.ts | 80 +-- .../source-control-primary-action-types.ts | 24 +- .../source-control-primary-action.ts | 455 ++++++++---------- .../source-control-create-review-intent.ts | 85 ++++ ...e-control-primary-action-decision-types.ts | 82 ++++ ...ce-control-primary-action-decision.test.ts | 160 ++++++ .../source-control-primary-action-decision.ts | 252 ++++++++++ ...source-control-primary-action-in-flight.ts | 50 ++ ...urce-control-primary-unpublished-action.ts | 47 ++ 15 files changed, 1733 insertions(+), 352 deletions(-) create mode 100644 mobile/src/source-control/mobile-source-control-primary-action-decision.ts create mode 100644 mobile/src/source-control/mobile-source-control-primary-action.test.ts create mode 100644 mobile/src/source-control/mobile-source-control-primary-action.ts create mode 100644 mobile/vitest.config.ts create mode 100644 src/shared/source-control-create-review-intent.ts create mode 100644 src/shared/source-control-primary-action-decision-types.ts create mode 100644 src/shared/source-control-primary-action-decision.test.ts create mode 100644 src/shared/source-control-primary-action-decision.ts create mode 100644 src/shared/source-control-primary-action-in-flight.ts create mode 100644 src/shared/source-control-primary-unpublished-action.ts diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index 74354407e3b..2eaf6dc4a1f 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -41,9 +41,9 @@ export function MobileSourceControlContent({ state, hostId, worktreeId, name }: unstagedCount, branchLabel, syncLabel, + primaryAction, stageAll, unstageAll, - commit, generateCommitMessage, cancelGenerateCommitMessage, abortConflictOperation, @@ -224,7 +224,7 @@ export function MobileSourceControlContent({ state, hostId, worktreeId, name }: placeholderTextColor={colors.textMuted} editable={busyAction === null && openingPath === null && openingBranchPath === null} returnKeyType="done" - onSubmitEditing={() => void commit()} + onSubmitEditing={primaryAction.onPress} /> )} [ styles.commitButton, - (!commitMessage.trim() || stagedCount === 0 || ioBusy) && styles.commitButtonDisabled, + primaryAction.disabled && styles.commitButtonDisabled, pressed && styles.commitButtonPressed ]} - onPress={() => void commit()} - disabled={!commitMessage.trim() || stagedCount === 0 || ioBusy} + onPress={primaryAction.onPress} + disabled={primaryAction.disabled} + accessibilityLabel={primaryAction.accessibilityLabel} + accessibilityHint={primaryAction.accessibilityHint} > - {busyAction === 'commit' ? ( + {primaryAction.loading ? ( ) : ( - Commit + {primaryAction.label} )} diff --git a/mobile/src/source-control/mobile-source-control-primary-action-decision.ts b/mobile/src/source-control/mobile-source-control-primary-action-decision.ts new file mode 100644 index 00000000000..eb0927bcff6 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-primary-action-decision.ts @@ -0,0 +1,268 @@ +import type { MobileGitUpstreamStatus } from './mobile-git-status' + +export type MobileSourceControlPrimaryActionKind = + | 'commit' + | 'stage' + | 'push' + | 'pull' + | 'sync' + | 'publish' + +export type MobileSourceControlRemoteOpKind = + | 'push' + | 'force_push' + | 'pull' + | 'sync' + | 'fetch' + | 'fast_forward' + | 'publish' + | 'rebase' + +export type MobileSourceControlPrimaryActionTitleIntent = + | 'commit_in_progress' + | 'force_push_in_progress' + | 'action_in_progress' + | 'remote_operation_in_progress' + | 'remote_operation_blocks_commit' + | 'resolve_conflicts_before_commit' + | 'commit_staged_changes' + | 'enter_commit_message' + | 'stage_all_changes' + | 'stage_file_to_commit' + | 'checkout_branch_before_publish' + | 'publish_branch' + | 'force_push_with_lease' + | 'sync_counts' + | 'pull_count' + | 'push_count' + | 'nothing_to_commit_up_to_date' + +export type MobileSourceControlPrimaryActionDecision = { + kind: MobileSourceControlPrimaryActionKind + disabled: boolean + labelIntent: MobileSourceControlPrimaryActionKind | 'force_push' + titleIntent: MobileSourceControlPrimaryActionTitleIntent + count?: number + ahead?: number + behind?: number + upstreamName?: string + requiresForceWithLease?: boolean +} + +export type MobileSourceControlPrimaryActionDecisionInputs = { + stagedCount: number + hasUnstagedChanges: boolean + hasStageableChanges: boolean + hasPartiallyStagedChanges: boolean + hasMessage: boolean + hasUnresolvedConflicts: boolean + isCommitting: boolean + isRemoteOperationActive: boolean + upstreamStatus: MobileGitUpstreamStatus | undefined + inFlightRemoteOpKind?: MobileSourceControlRemoteOpKind | null + branchCommitsAhead?: number + hasCurrentBranch?: boolean +} + +// Why: Metro cannot load runtime modules from the desktop/root `src/shared` +// tree. Keep this mobile mirror narrow and parity-tested against the shared +// commit-area decision core so the semantic ladder cannot drift silently. +export function resolveMobileSourceControlCommitAreaPrimaryActionDecision( + inputs: MobileSourceControlPrimaryActionDecisionInputs +): MobileSourceControlPrimaryActionDecision { + const { + stagedCount, + hasUnstagedChanges, + hasStageableChanges, + hasMessage, + hasUnresolvedConflicts, + isCommitting, + isRemoteOperationActive, + upstreamStatus, + branchCommitsAhead, + hasCurrentBranch = true + } = inputs + + if (isCommitting) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'commit_in_progress', + disabled: true + } + } + + if (isRemoteOperationActive) { + return resolveMobilePrimaryActionDuringRemoteOp(inputs) + } + + if (hasUnresolvedConflicts) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'resolve_conflicts_before_commit', + disabled: true + } + } + + const hasStaged = stagedCount > 0 + + if (hasStaged && hasMessage) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'commit_staged_changes', + disabled: false + } + } + + if (hasStaged && !hasMessage) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'enter_commit_message', + disabled: true + } + } + + if (!hasStaged && hasStageableChanges) { + return { + kind: 'stage', + labelIntent: 'stage', + titleIntent: 'stage_all_changes', + disabled: false + } + } + + if (!upstreamStatus) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'stage_file_to_commit', + disabled: true + } + } + + if (!upstreamStatus.hasUpstream) { + if (!hasCurrentBranch) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'checkout_branch_before_publish', + disabled: true + } + } + return { + kind: 'publish', + labelIntent: 'publish', + titleIntent: 'publish_branch', + disabled: false + } + } + + if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) { + if (shouldForcePushWithLeaseForMobileUpstream(upstreamStatus)) { + return { + kind: 'push', + labelIntent: 'force_push', + titleIntent: 'force_push_with_lease', + disabled: false, + count: branchCommitsAhead, + upstreamName: upstreamStatus.upstreamName, + requiresForceWithLease: true + } + } + return { + kind: 'sync', + labelIntent: 'sync', + titleIntent: 'sync_counts', + disabled: false, + ahead: upstreamStatus.ahead, + behind: upstreamStatus.behind + } + } + + if (upstreamStatus.behind > 0) { + return { + kind: 'pull', + labelIntent: 'pull', + titleIntent: 'pull_count', + disabled: false, + count: upstreamStatus.behind + } + } + + if (upstreamStatus.ahead > 0) { + return { + kind: 'push', + labelIntent: 'push', + titleIntent: 'push_count', + disabled: false, + count: upstreamStatus.ahead + } + } + + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date', + disabled: true + } +} + +function resolveMobilePrimaryActionDuringRemoteOp( + inputs: MobileSourceControlPrimaryActionDecisionInputs +): MobileSourceControlPrimaryActionDecision { + const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs + const candidate = resolveMobileSourceControlCommitAreaPrimaryActionDecision({ + ...inputs, + isRemoteOperationActive: false + }) + const inFlightIsPrimaryKind = + inFlightRemoteOpKind === 'push' || + inFlightRemoteOpKind === 'pull' || + inFlightRemoteOpKind === 'sync' || + inFlightRemoteOpKind === 'publish' + + if (inFlightRemoteOpKind === 'force_push') { + return { + kind: 'push', + labelIntent: 'force_push', + titleIntent: 'force_push_in_progress', + disabled: true, + requiresForceWithLease: true + } + } + + if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) { + return { + kind: inFlightRemoteOpKind, + labelIntent: inFlightRemoteOpKind, + titleIntent: 'action_in_progress', + disabled: true + } + } + + const titleIntent = hasUnresolvedConflicts + ? 'resolve_conflicts_before_commit' + : candidate.kind === 'commit' + ? 'remote_operation_blocks_commit' + : 'remote_operation_in_progress' + + return { + ...candidate, + titleIntent, + disabled: true + } +} + +function shouldForcePushWithLeaseForMobileUpstream( + status: MobileGitUpstreamStatus | undefined +): boolean { + return ( + status?.hasUpstream === true && + status.ahead > 0 && + status.behind > 0 && + status.behindCommitsArePatchEquivalent === true + ) +} diff --git a/mobile/src/source-control/mobile-source-control-primary-action.test.ts b/mobile/src/source-control/mobile-source-control-primary-action.test.ts new file mode 100644 index 00000000000..39229161525 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-primary-action.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision' +import { + resolveMobileSourceControlCommitAreaPrimaryActionDecision, + type MobileSourceControlPrimaryActionDecisionInputs +} from './mobile-source-control-primary-action-decision' +import { + buildMobileSourceControlPrimaryAction, + type MobileSourceControlPrimaryActionArgs, + type MobileSourceControlPrimaryActionHandlers +} from './mobile-source-control-primary-action' +import type { MobileGitStatusResult } from './mobile-git-status' + +function handlers(): MobileSourceControlPrimaryActionHandlers { + return { + commit: vi.fn(async () => true), + stageAll: vi.fn(async () => undefined), + runActionSheetGitSequence: vi.fn(async () => undefined), + runActionSheetGitSync: vi.fn(async () => undefined) + } +} + +function status(overrides: Partial = {}): MobileGitStatusResult { + return { + entries: [], + conflictOperation: 'unknown', + branch: 'feature', + head: 'abc123', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + ...overrides + } +} + +function args(overrides: Partial = {}) { + return { + status: status(), + hasUnresolvedConflicts: false, + stageablePaths: [], + stagedCount: 0, + unstagedCount: 0, + commitMessage: '', + busyAction: null, + openingPath: null, + openingBranchPath: null, + branchCompareResult: null, + handlers: handlers(), + ...overrides + } +} + +describe('buildMobileSourceControlPrimaryAction', () => { + it('selects Stage All for unstaged work and dispatches the stage runner', () => { + const h = handlers() + const action = buildMobileSourceControlPrimaryAction( + args({ + stageablePaths: ['a.ts'], + unstagedCount: 1, + handlers: h + }) + ) + expect(action.label).toBe('Stage All') + expect(action.disabled).toBe(false) + action.onPress() + expect(h.stageAll).toHaveBeenCalledTimes(1) + }) + + it('selects Commit for staged work with a message and dispatches commit', () => { + const h = handlers() + const action = buildMobileSourceControlPrimaryAction( + args({ + stagedCount: 1, + commitMessage: 'Ship it', + handlers: h + }) + ) + expect(action.label).toBe('Commit') + expect(action.disabled).toBe(false) + action.onPress() + expect(h.commit).toHaveBeenCalledTimes(1) + }) + + it('selects Publish Branch only when a current branch exists', () => { + expect( + buildMobileSourceControlPrimaryAction( + args({ status: status({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } }) }) + ).label + ).toBe('Publish Branch') + const detached = buildMobileSourceControlPrimaryAction( + args({ + status: status({ + branch: undefined, + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + }) + }) + ) + expect(detached.label).toBe('Commit') + expect(detached.disabled).toBe(true) + }) + + it('dispatches force push with lease when the shared decision requires it', () => { + const h = handlers() + const action = buildMobileSourceControlPrimaryAction( + args({ + status: status({ + upstreamStatus: { + hasUpstream: true, + ahead: 10, + behind: 2, + behindCommitsArePatchEquivalent: true + } + }), + branchCompareResult: { + entries: [], + summary: { + status: 'ready', + baseRef: 'main', + baseOid: 'base', + compareRef: 'HEAD', + changedFiles: 0, + commitsAhead: 3, + headOid: 'abc', + mergeBase: 'def' + } + }, + handlers: h + }) + ) + expect(action.label).toBe('Force Push') + expect(action.requiresForceWithLease).toBe(true) + action.onPress() + expect(h.runActionSheetGitSequence).toHaveBeenCalledWith('force-push', [ + { method: 'git.push', params: { forceWithLease: true } } + ]) + }) + + it('disables the button for unresolved entries even during a conflict operation', () => { + const action = buildMobileSourceControlPrimaryAction( + args({ + status: status({ conflictOperation: 'merge' }), + hasUnresolvedConflicts: true, + stagedCount: 1, + commitMessage: 'Resolve' + }) + ) + expect(action.label).toBe('Commit') + expect(action.disabled).toBe(true) + expect(action.accessibilityHint).toBe('Resolve conflicts before committing.') + }) + + it('does not block solely because a conflict operation exists without unresolved entries', () => { + const action = buildMobileSourceControlPrimaryAction( + args({ + status: status({ conflictOperation: 'merge' }), + hasUnresolvedConflicts: false, + stagedCount: 1, + commitMessage: 'Resolve' + }) + ) + expect(action.disabled).toBe(false) + }) +}) + +function decisionInputs( + overrides: Partial = {} +): MobileSourceControlPrimaryActionDecisionInputs { + return { + stagedCount: 0, + hasUnstagedChanges: false, + hasStageableChanges: false, + hasPartiallyStagedChanges: false, + hasMessage: false, + hasUnresolvedConflicts: false, + isCommitting: false, + isRemoteOperationActive: false, + upstreamStatus: undefined, + ...overrides + } +} + +describe('mobile source-control primary action decision parity', () => { + it.each([ + { + name: 'dirty tree stages first', + input: decisionInputs({ + hasUnstagedChanges: true, + hasStageableChanges: true, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 } + }) + }, + { + name: 'staged message commits', + input: decisionInputs({ stagedCount: 1, hasMessage: true }) + }, + { + name: 'staged without message blocks commit', + input: decisionInputs({ stagedCount: 1, hasMessage: false }) + }, + { + name: 'unresolved conflicts block commit', + input: decisionInputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true }) + }, + { + name: 'unpublished branch publishes', + input: decisionInputs({ + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, + hasCurrentBranch: true + }) + }, + { + name: 'detached head blocks publish', + input: decisionInputs({ + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, + hasCurrentBranch: false + }) + }, + { + name: 'tracked ahead pushes', + input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } }) + }, + { + name: 'tracked behind pulls', + input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } }) + }, + { + name: 'tracked diverged syncs', + input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } }) + }, + { + name: 'patch-equivalent diverged force-pushes with lease', + input: decisionInputs({ + branchCommitsAhead: 4, + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 10, + behind: 2, + behindCommitsArePatchEquivalent: true + } + }) + }, + { + name: 'in-flight pull mirrors pull', + input: decisionInputs({ + isRemoteOperationActive: true, + inFlightRemoteOpKind: 'pull', + upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } + }) + } + ])('matches the shared commit-area decision for $name', ({ input }) => { + expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual( + resolveSourceControlCommitAreaPrimaryActionDecision(input) + ) + }) +}) diff --git a/mobile/src/source-control/mobile-source-control-primary-action.ts b/mobile/src/source-control/mobile-source-control-primary-action.ts new file mode 100644 index 00000000000..f62a7770d16 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-primary-action.ts @@ -0,0 +1,241 @@ +import { + resolveMobileSourceControlCommitAreaPrimaryActionDecision, + type MobileSourceControlPrimaryActionDecision, + type MobileSourceControlPrimaryActionKind, + type MobileSourceControlRemoteOpKind +} from './mobile-source-control-primary-action-decision' +import type { MobileGitBranchCompareResult } from './mobile-branch-compare' +import type { MobileGitStatusResult } from './mobile-git-status' + +type GitStep = { method: string; params?: Record } + +export type MobileSourceControlPrimaryAction = { + kind: MobileSourceControlPrimaryActionKind + label: string + accessibilityLabel: string + accessibilityHint: string + disabled: boolean + loading: boolean + requiresForceWithLease?: boolean + onPress: () => void +} + +export type MobileSourceControlPrimaryActionHandlers = { + commit: () => Promise + stageAll: () => Promise + runActionSheetGitSequence: (actionId: string, steps: GitStep[]) => Promise + runActionSheetGitSync: () => Promise +} + +export type MobileSourceControlPrimaryActionArgs = { + status: MobileGitStatusResult | null + hasUnresolvedConflicts: boolean + stageablePaths: readonly string[] + stagedCount: number + unstagedCount: number + commitMessage: string + busyAction: string | null + openingPath: string | null + openingBranchPath: string | null + branchCompareResult: MobileGitBranchCompareResult | null + handlers: MobileSourceControlPrimaryActionHandlers +} + +export function buildMobileSourceControlPrimaryAction( + args: MobileSourceControlPrimaryActionArgs +): MobileSourceControlPrimaryAction { + const decision = resolveMobileSourceControlCommitAreaPrimaryActionDecision({ + stagedCount: args.stagedCount, + hasUnstagedChanges: args.unstagedCount > 0, + hasStageableChanges: args.stageablePaths.length > 0, + // Why: the commit-area decision keeps the desktop input shape, but partial + // staging only matters to commit eligibility/dropdowns. Avoid an extra entry scan. + hasPartiallyStagedChanges: false, + hasMessage: args.commitMessage.trim().length > 0, + hasUnresolvedConflicts: args.hasUnresolvedConflicts, + isCommitting: args.busyAction === 'commit', + isRemoteOperationActive: isMobileRemoteOperationActive(args.busyAction), + inFlightRemoteOpKind: getInFlightRemoteOpKind(args.busyAction), + upstreamStatus: args.status?.upstreamStatus, + branchCommitsAhead: getMobileBranchCommitsAhead(args), + hasCurrentBranch: Boolean(args.status?.branch) + }) + const ioBusy = + args.busyAction !== null || args.openingPath !== null || args.openingBranchPath !== null + const disabled = decision.disabled || ioBusy + + return { + kind: decision.kind, + label: getMobilePrimaryActionLabel(decision), + accessibilityLabel: getMobilePrimaryActionLabel(decision), + accessibilityHint: getMobilePrimaryActionHint(decision), + disabled, + loading: isLoadingDecision(decision, args.busyAction), + requiresForceWithLease: decision.requiresForceWithLease, + onPress: () => { + if (disabled) { + return + } + void runMobilePrimaryAction(decision, args.handlers) + } + } +} + +function isMobileRemoteOperationActive(busyAction: string | null): boolean { + return getInFlightRemoteOpKind(busyAction) !== null +} + +function getInFlightRemoteOpKind( + busyAction: string | null +): MobileSourceControlRemoteOpKind | null { + switch (busyAction) { + case 'push': + case 'commit-push': + case 'push-create-pr': + return 'push' + case 'force-push': + return 'force_push' + case 'pull': + return 'pull' + case 'sync': + case 'commit-sync': + return 'sync' + case 'fetch': + return 'fetch' + case 'publish': + return 'publish' + case 'fast-forward': + return 'fast_forward' + case 'rebase': + return 'rebase' + default: + return null + } +} + +function getMobileBranchCommitsAhead( + args: MobileSourceControlPrimaryActionArgs +): number | undefined { + const summary = args.branchCompareResult?.summary + if (summary?.status === 'ready' && summary.commitsAhead !== undefined) { + return summary.commitsAhead + } + const upstream = args.status?.upstreamStatus + return upstream?.hasUpstream ? upstream.ahead : undefined +} + +function getMobilePrimaryActionLabel(decision: MobileSourceControlPrimaryActionDecision): string { + if (decision.requiresForceWithLease) { + return 'Force Push' + } + switch (decision.kind) { + case 'commit': + return 'Commit' + case 'stage': + return 'Stage All' + case 'push': + return 'Push' + case 'pull': + return 'Pull' + case 'sync': + return 'Sync' + case 'publish': + return 'Publish Branch' + } +} + +function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDecision): string { + switch (decision.titleIntent) { + case 'commit_in_progress': + return 'Commit in progress.' + case 'force_push_in_progress': + return 'Force push in progress.' + case 'action_in_progress': + case 'remote_operation_in_progress': + return 'Remote operation in progress.' + case 'remote_operation_blocks_commit': + return 'Try again once the remote operation finishes.' + case 'resolve_conflicts_before_commit': + return 'Resolve conflicts before committing.' + case 'commit_staged_changes': + return 'Commit staged changes.' + case 'enter_commit_message': + return 'Enter a commit message to commit.' + case 'stage_all_changes': + return 'Stage all changes.' + case 'stage_file_to_commit': + return 'Stage at least one file to commit.' + case 'checkout_branch_before_publish': + return 'Check out a branch before publishing commits.' + case 'publish_branch': + return 'Publish this branch to origin.' + case 'force_push_with_lease': + return 'Force push with lease to update the remote branch.' + case 'sync_counts': + return `Pull ${decision.behind ?? 0}, push ${decision.ahead ?? 0}.` + case 'pull_count': + return `Pull ${decision.count ?? 0} commit${decision.count === 1 ? '' : 's'}.` + case 'push_count': + return `Push ${decision.count ?? 0} commit${decision.count === 1 ? '' : 's'}.` + case 'nothing_to_commit_up_to_date': + return 'Nothing to commit. Branch is up to date.' + } +} + +function isLoadingDecision( + decision: MobileSourceControlPrimaryActionDecision, + busyAction: string | null +): boolean { + switch (decision.kind) { + case 'commit': + return busyAction === 'commit' + case 'stage': + return busyAction === 'stage-all' + case 'push': + return ( + busyAction === 'push' || + busyAction === 'force-push' || + busyAction === 'commit-push' || + busyAction === 'push-create-pr' + ) + case 'pull': + return busyAction === 'pull' + case 'sync': + return busyAction === 'sync' || busyAction === 'commit-sync' + case 'publish': + return busyAction === 'publish' + } +} + +async function runMobilePrimaryAction( + decision: MobileSourceControlPrimaryActionDecision, + handlers: MobileSourceControlPrimaryActionHandlers +): Promise { + switch (decision.kind) { + case 'commit': + await handlers.commit() + return + case 'stage': + await handlers.stageAll() + return + case 'push': { + const params = decision.requiresForceWithLease ? { forceWithLease: true } : undefined + await handlers.runActionSheetGitSequence( + decision.requiresForceWithLease ? 'force-push' : 'push', + [{ method: 'git.push', params }] + ) + return + } + case 'pull': + await handlers.runActionSheetGitSequence('pull', [{ method: 'git.pull' }]) + return + case 'sync': + await handlers.runActionSheetGitSync() + return + case 'publish': + await handlers.runActionSheetGitSequence('publish', [ + { method: 'git.push', params: { publish: true } } + ]) + return + } +} diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts index 875715ad9b0..ecac9892b9f 100644 --- a/mobile/src/source-control/use-mobile-source-control-state.ts +++ b/mobile/src/source-control/use-mobile-source-control-state.ts @@ -7,6 +7,7 @@ import type { MobilePrPrefill } from './mobile-pr-create' import { useMobileGitRequests } from './use-mobile-git-requests' import { useMobileSourceControlLoaders } from './use-mobile-source-control-loaders' import { useMobileSourceControlOpeners } from './use-mobile-source-control-openers' +import { buildMobileSourceControlPrimaryAction } from './mobile-source-control-primary-action' import { useMobileSourceControlRunners } from './use-mobile-source-control-runners' import type { RuntimeGitLocalBranches } from '../../../src/shared/runtime-types' import { @@ -157,6 +158,10 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara const unstageablePaths = useMemo(() => getUnstageablePaths(entries), [entries]) const stagedCount = useMemo(() => countStagedEntries(entries), [entries]) const unstagedCount = useMemo(() => countUnstagedEntries(entries), [entries]) + const hasUnresolvedConflicts = useMemo( + () => entries.some((entry) => entry.conflictStatus === 'unresolved'), + [entries] + ) const branchLabel = formatBranchLabel(status?.branch, status?.head) const upstream = status?.upstreamStatus const upstreamKnown = upstream !== undefined @@ -200,6 +205,43 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara setPrPrefill, setShowPrSheet }) + const primaryAction = useMemo( + () => + buildMobileSourceControlPrimaryAction({ + status, + hasUnresolvedConflicts, + stageablePaths, + stagedCount, + unstagedCount, + commitMessage, + busyAction, + openingPath, + openingBranchPath, + branchCompareResult, + handlers: { + commit: runners.commit, + stageAll: runners.stageAll, + runActionSheetGitSequence: runners.runActionSheetGitSequence, + runActionSheetGitSync: runners.runActionSheetGitSync + } + }), + [ + branchCompareResult, + busyAction, + commitMessage, + hasUnresolvedConflicts, + openingBranchPath, + openingPath, + runners.commit, + runners.runActionSheetGitSequence, + runners.runActionSheetGitSync, + runners.stageAll, + stageablePaths, + stagedCount, + status, + unstagedCount + ] + ) return { client, @@ -253,6 +295,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara upstream, upstreamKnown, syncLabel, + primaryAction, // actions loadStatus, openFile, diff --git a/mobile/vitest.config.ts b/mobile/vitest.config.ts new file mode 100644 index 00000000000..a3963ef68b2 --- /dev/null +++ b/mobile/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + +const tsconfigRaw = JSON.stringify({ + compilerOptions: { + jsx: 'react-jsx', + module: 'esnext', + moduleResolution: 'bundler', + strict: true, + target: 'es2022' + } +}) + +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + esbuild: { + tsconfigRaw + }, + optimizeDeps: { + esbuildOptions: { + tsconfigRaw + } + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'] + } +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts index dfe3d2437dd..2a8236dc686 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.ts @@ -1,78 +1,14 @@ -import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' -import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' -import { supportsHostedReviewCreation } from '../../../../shared/hosted-review-creation-providers' -import type { GitUpstreamStatus } from '../../../../shared/types' import type { PrimaryAction } from './source-control-primary-action-types' +import { + resolveCreateReviewIntentEligibility, + type CreateReviewIntentEligibility, + type CreateReviewIntentKind +} from '../../../../shared/source-control-create-review-intent' -export type CreatePrIntentKind = - | 'dirty' - | 'message_required' - | 'no_upstream' - | 'needs_push' - | 'force_push' +export type CreatePrIntentKind = CreateReviewIntentKind +export type CreatePrIntentEligibility = CreateReviewIntentEligibility -export type CreatePrIntentEligibility = { - eligible: boolean - kind: CreatePrIntentKind | null -} - -export function resolveCreatePrIntentEligibility({ - stagedCount, - hasStageableChanges, - hasMessage, - hasUnresolvedConflicts, - upstreamStatus, - hostedReviewCreation, - branchCommitsAhead, - hasCurrentBranch = true -}: { - stagedCount: number - hasStageableChanges: boolean - hasMessage: boolean - hasUnresolvedConflicts: boolean - upstreamStatus: GitUpstreamStatus | undefined - hostedReviewCreation?: HostedReviewCreationEligibility | null - branchCommitsAhead?: number - hasCurrentBranch?: boolean -}): CreatePrIntentEligibility { - if ( - hasUnresolvedConflicts || - !hasCurrentBranch || - !hostedReviewCreation || - hostedReviewCreation.canCreate || - !supportsHostedReviewCreation(hostedReviewCreation.provider) - ) { - return { eligible: false, kind: null } - } - - if (hostedReviewCreation.blockedReason === 'dirty') { - if (stagedCount > 0 && !hasMessage) { - return { eligible: true, kind: 'message_required' } - } - return { eligible: stagedCount > 0 || hasStageableChanges, kind: 'dirty' } - } - - if (hostedReviewCreation.blockedReason === 'no_upstream') { - const hasPublishableCommits = branchCommitsAhead === undefined ? false : branchCommitsAhead > 0 - return { - eligible: hasPublishableCommits || stagedCount > 0 || hasStageableChanges, - kind: 'no_upstream' - } - } - - if (hostedReviewCreation.blockedReason === 'needs_push') { - return { eligible: true, kind: 'needs_push' } - } - - if ( - hostedReviewCreation.blockedReason === 'needs_sync' && - shouldForcePushWithLeaseForUpstream(upstreamStatus) - ) { - return { eligible: true, kind: 'force_push' } - } - - return { eligible: false, kind: null } -} +export const resolveCreatePrIntentEligibility = resolveCreateReviewIntentEligibility export function resolveVisibleCreatePrHeaderAction({ createPrHeaderAction diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts index 2694fc82efc..b3f79e8a69e 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action-types.ts @@ -1,5 +1,9 @@ import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' import type { GitUpstreamStatus, PRState } from '../../../../shared/types' +import type { + SourceControlPrimaryActionKind, + SourceControlRemoteOpKind +} from '../../../../shared/source-control-primary-action-decision-types' // Why: the primary button collapses to one-label-per-action. Compound // kinds ('commit_push', 'commit_sync', 'commit_publish') live in @@ -8,15 +12,7 @@ import type { GitUpstreamStatus, PRState } from '../../../../shared/types' // `handlePrimaryClick` switch exhaustively over only the kinds the // primary can actually emit, and it kills the compound-commit branch in // the isRemoteOperationActive tooltip below at compile time. -export type PrimaryActionKind = - | 'commit' - | 'stage' - | 'push' - | 'pull' - | 'sync' - | 'publish' - | 'create_pr_intent' - | 'create_pr' +export type PrimaryActionKind = SourceControlPrimaryActionKind // Why: the in-flight remote op tracker stores which action the user actually // triggered, so the primary button can mirror that label/spinner instead of @@ -24,15 +20,7 @@ export type PrimaryActionKind = // kinds are included because they participate in the busy flag, but they are // intentionally NOT in PrimaryActionKind — when Fetch is in flight the primary // keeps its natural label, while Force Push maps back to the push icon/slot. -export type RemoteOpKind = - | 'push' - | 'force_push' - | 'pull' - | 'sync' - | 'fetch' - | 'fast_forward' - | 'publish' - | 'rebase' +export type RemoteOpKind = SourceControlRemoteOpKind export type PrimaryAction = { kind: PrimaryActionKind diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts index ad89186d569..1eee270ee09 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts @@ -1,25 +1,22 @@ // Why: split from the combined primary+dropdown module because the primary and dropdown are independent derivations with different priority ladders; together they exceed the max-lines budget and tangle unrelated concerns. -import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' +import { + resolveSourceControlCommitAreaPrimaryActionDecision, + resolveSourceControlPrimaryActionDecision +} from '../../../../shared/source-control-primary-action-decision' +import type { SourceControlPrimaryActionDecision } from '../../../../shared/source-control-primary-action-decision-types' import { translate } from '@/i18n/i18n' import { localizedHostedReviewCopy, resolveSupportedHostedReviewCopyProvider } from '@/i18n/hosted-review-localized-copy' import { type PrimaryAction, type PrimaryActionInputs } from './source-control-primary-action-types' -import { resolvePrimaryActionDuringRemoteOp } from './source-control-primary-action-in-flight' import { describeForcePushWithLease, describePullCount, describePushCount, describeSyncCounts } from './source-control-primary-action-titles' -import { resolveLinkedReviewPrimaryAction } from './source-control-linked-review-primary-action' -import { - resolveCreatePrIntentInFlightPrimaryAction, - resolveCreatePrIntentPrimaryAction -} from './source-control-primary-create-pr-intent-action' -import { resolveUnpublishedPrimaryAction } from './source-control-primary-unpublished-action' export type { PrimaryActionKind, @@ -28,10 +25,8 @@ export type { PrimaryActionInputs } from './source-control-primary-action-types' -// Why: this module owns the pure state-machine logic for the Source Control -// primary action (split button). Keeping the logic outside the React component -// makes it straightforward to unit-test each row of the priority table without -// spinning up a renderer. +// Why: the shared module owns the pure state-machine logic; this renderer +// adapter keeps localized copy and the historical exported shape in place. /** * Resolve the primary split-button action. @@ -54,250 +49,200 @@ export type { * through "Publish Branch" on every worktree switch. */ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction { - const { - stagedCount, - hasUnstagedChanges, - hasStageableChanges, - hasMessage, - hasUnresolvedConflicts, - isCommitting, - isRemoteOperationActive, - upstreamStatus, - prState, - isPRStateLoading, - hostedReviewCreation, - branchCommitsAhead, - hasCurrentBranch = true, - canPushLinkedReviewWithoutUpstream = false, - isPrIntentInFlight = false - } = inputs - - if (isPrIntentInFlight) { - return resolveCreatePrIntentInFlightPrimaryAction(inputs) - } - - // 1. Commit in flight — lock the primary no matter what else is true. - if (isCommitting) { - return { - kind: 'commit', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f', - 'Commit' - ), - title: translate( - 'auto.components.right.sidebar.source.control.primary.action.16aee3a5c1', - 'Commit in progress…' - ), - disabled: true - } - } - - if (isRemoteOperationActive) { - return resolvePrimaryActionDuringRemoteOp(inputs, resolvePrimaryAction) - } - - // 3. Unresolved conflicts block any commit path. - if (hasUnresolvedConflicts) { - return { - kind: 'commit', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f', - 'Commit' - ), - title: translate( - 'auto.components.right.sidebar.source.control.primary.action.a6457b46a7', - 'Resolve conflicts before committing' - ), - disabled: true - } - } - - const createPrIntent = resolveCreatePrIntentPrimaryAction(inputs) - if (createPrIntent) { - return createPrIntent - } - - const hasStaged = stagedCount > 0 - const hasOpenHostedReview = prState === 'open' || prState === 'draft' - - // 5. Has staged files + message → plain Commit. The primary button never - // compounds ("Commit & Push" etc.) — after the commit lands, the primary - // naturally rotates to the appropriate remote action (Push / Sync / - // Publish Branch) via step 7 below. Users who want the one-click - // compound flow can still reach it from the dropdown. - if (hasStaged && hasMessage) { - return { - kind: 'commit', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f', - 'Commit' - ), - title: translate( - 'auto.components.right.sidebar.source.control.primary.action.ab41fb926b', - 'Commit staged changes' - ), - disabled: false - } - } - - // 6. Has staged files but no message — user just needs to type something. - if (hasStaged && !hasMessage) { - return { - kind: 'commit', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f', - 'Commit' - ), - title: translate( - 'auto.components.right.sidebar.source.control.primary.action.f01f16d77f', - 'Enter a commit message to commit' - ), - disabled: true - } - } - - // 6b. Nothing staged but local changes exist — surface staging as the - // primary so dirty trees don't invite a remote op (pull/sync would fail - // with uncommitted changes; push/publish skips the actual user need). - // Sits before the upstream-status checks so it works regardless of - // whether upstream has resolved yet. - if (!hasStaged && hasStageableChanges) { - return { - kind: 'stage', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.18a0fca877', - 'Stage All' - ), - title: translate( - 'auto.components.right.sidebar.source.control.primary.action.5a477d80cb', - 'Stage all changes' - ), - disabled: false - } - } - - // 7. Clean tree + no staged files → adaptive remote action. - if (!upstreamStatus) { - return { - kind: 'commit', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f', - 'Commit' - ), - title: translate( - 'auto.components.right.sidebar.source.control.primary.action.fa3bd4f40c', - 'Stage at least one file to commit' - ), - disabled: true - } - } - - if (!upstreamStatus.hasUpstream) { - const unpublishedAction = resolveUnpublishedPrimaryAction({ - hasCurrentBranch, - isPRStateLoading, - prState - }) - - if (unpublishedAction.kind === 'publish') { - const linkedReviewAction = resolveLinkedReviewPrimaryAction({ - hasOpenHostedReview, - canPushLinkedReviewWithoutUpstream - }) - if (linkedReviewAction) { - return linkedReviewAction - } - } - - return unpublishedAction - } - - if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) { - if (shouldForcePushWithLeaseForUpstream(upstreamStatus)) { - return { - kind: 'push', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.390abeab93', - 'Force Push' - ), - title: describeForcePushWithLease(branchCommitsAhead, upstreamStatus.upstreamName), - disabled: false - } - } - return { - kind: 'sync', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.795f1509c5', - 'Sync' - ), - title: describeSyncCounts(upstreamStatus.ahead, upstreamStatus.behind), - disabled: false - } - } - if (upstreamStatus.behind > 0) { - return { - kind: 'pull', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.d64292a938', - 'Pull' - ), - title: describePullCount(upstreamStatus.behind), - disabled: false - } - } - if (upstreamStatus.ahead > 0) { - return { - kind: 'push', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.95550cff15', - 'Push' - ), - title: describePushCount(upstreamStatus.ahead), - disabled: false - } - } - - if (hostedReviewCreation?.canCreate) { - const copy = localizedHostedReviewCopy( - resolveSupportedHostedReviewCopyProvider(hostedReviewCreation.provider) - ) - return { - kind: 'create_pr', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.e7ffa46946', - 'Create {{value0}}', - { value0: copy.shortLabel } - ), - title: translate( - 'auto.components.right.sidebar.source.control.primary.action.946a8a05ea', - 'Create a {{value0}} for this branch', - { value0: copy.reviewLabel } - ), - disabled: false - } - } - - // Clean + tracked + in sync — distinguish truly clean from work that still - // needs staging before commit can proceed. - return { - kind: 'commit', - label: translate( - 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f', - 'Commit' - ), - title: hasUnstagedChanges - ? 'Stage at least one file to commit' - : 'Nothing to commit. Branch is up to date.', - disabled: true - } + return toRendererPrimaryAction(resolveSourceControlPrimaryActionDecision(inputs), inputs) } export function resolveCommitAreaPrimaryAction(inputs: PrimaryActionInputs): PrimaryAction { - // Why: review creation is additive chrome. The commit area should keep the - // same local/remote primary action it would have without review eligibility. - return resolvePrimaryAction({ - ...inputs, - hostedReviewCreation: null, - isPrIntentInFlight: false - }) + return toRendererPrimaryAction( + resolveSourceControlCommitAreaPrimaryActionDecision(inputs), + inputs + ) +} + +function toRendererPrimaryAction( + decision: SourceControlPrimaryActionDecision, + inputs: PrimaryActionInputs +): PrimaryAction { + return { + kind: decision.kind, + label: resolvePrimaryActionLabel(decision, inputs), + title: resolvePrimaryActionTitle(decision, inputs), + disabled: decision.disabled + } +} + +function resolvePrimaryActionLabel( + decision: SourceControlPrimaryActionDecision, + inputs: PrimaryActionInputs +): string { + if (decision.labelIntent === 'force_push') { + return translate( + 'auto.components.right.sidebar.source.control.primary.action.390abeab93', + 'Force Push' + ) + } + if (decision.labelIntent === 'create_pr') { + const copy = localizedHostedReviewCopy( + resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation?.provider) + ) + return translate( + 'auto.components.right.sidebar.source.control.primary.action.e7ffa46946', + 'Create {{value0}}', + { value0: copy.shortLabel } + ) + } + switch (decision.labelIntent) { + case 'commit': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f', + 'Commit' + ) + case 'stage': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.18a0fca877', + 'Stage All' + ) + case 'push': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.95550cff15', + 'Push' + ) + case 'pull': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.d64292a938', + 'Pull' + ) + case 'sync': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.795f1509c5', + 'Sync' + ) + case 'publish': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.7b4d02e6b8', + 'Publish Branch' + ) + case 'create_pr_intent': + return resolvePrimaryActionLabel({ ...decision, labelIntent: 'create_pr' }, inputs) + } +} + +function resolvePrimaryActionTitle( + decision: SourceControlPrimaryActionDecision, + inputs: PrimaryActionInputs +): string { + const copy = localizedHostedReviewCopy( + resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation?.provider) + ) + switch (decision.titleIntent) { + case 'commit_in_progress': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.16aee3a5c1', + 'Commit in progress…' + ) + case 'force_push_in_progress': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.74fc171e99', + 'Force Push in progress…' + ) + case 'action_in_progress': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.484f45c439', + '{{value0}} in progress…', + { value0: resolvePrimaryActionLabel(decision, inputs) } + ) + case 'remote_operation_in_progress': + return 'Remote operation in progress…' + case 'remote_operation_blocks_commit': + return 'Remote operation in progress — try again once it finishes' + case 'resolve_conflicts_before_commit': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.a6457b46a7', + 'Resolve conflicts before committing' + ) + case 'prepare_review': + if (decision.disabled) { + return translate( + 'auto.components.right.sidebar.source.control.primary.action.d37e68f61d', + 'Preparing branch for review…' + ) + } + return translate( + 'auto.components.right.sidebar.source.control.primary.action.c72e5e65d1', + 'Prepare this branch and create a {{value0}}', + { value0: copy.reviewLabel } + ) + case 'commit_staged_changes': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.ab41fb926b', + 'Commit staged changes' + ) + case 'enter_commit_message': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.f01f16d77f', + 'Enter a commit message to commit' + ) + case 'stage_all_changes': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.5a477d80cb', + 'Stage all changes' + ) + case 'stage_file_to_commit': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.fa3bd4f40c', + 'Stage at least one file to commit' + ) + case 'checkout_branch_before_publish': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.e61b0d7a3c', + 'Check out a branch before publishing commits.' + ) + case 'checking_review_status': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.41d4bcf157', + 'Checking PR status…' + ) + case 'review_already_merged': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.3d5dccef0b', + 'Nothing to commit. PR is already merged.' + ) + case 'publish_branch': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.1884cf34af', + 'Publish this branch to origin' + ) + case 'push_linked_review': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.1d47e850cf', + 'Push updates to the linked review branch' + ) + case 'linked_review_target_unavailable': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.c39d0c75c3', + 'Linked review branch target is unavailable.' + ) + case 'force_push_with_lease': + return describeForcePushWithLease(decision.count, decision.upstreamName) + case 'sync_counts': + return describeSyncCounts(decision.ahead ?? 0, decision.behind ?? 0) + case 'pull_count': + return describePullCount(decision.count ?? 0) + case 'push_count': + return describePushCount(decision.count ?? 0) + case 'create_review': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.946a8a05ea', + 'Create a {{value0}} for this branch', + { value0: copy.reviewLabel } + ) + case 'nothing_to_commit_up_to_date': + return 'Nothing to commit. Branch is up to date.' + case 'checking_review_creation': + return translate( + 'auto.components.right.sidebar.source.control.primary.action.h3i4j5k607', + 'Checking whether this branch can create a {{value0}}…', + { value0: copy.reviewLabel } + ) + } } diff --git a/src/shared/source-control-create-review-intent.ts b/src/shared/source-control-create-review-intent.ts new file mode 100644 index 00000000000..2ebb932aca4 --- /dev/null +++ b/src/shared/source-control-create-review-intent.ts @@ -0,0 +1,85 @@ +import { shouldForcePushWithLeaseForUpstream } from './git-upstream-status' +import type { HostedReviewCreationEligibility } from './hosted-review' +import { supportsHostedReviewCreation } from './hosted-review-creation-providers' +import type { GitUpstreamStatus } from './git-status-types' +import type { SourceControlPrimaryActionDecision } from './source-control-primary-action-decision-types' + +export type CreateReviewIntentKind = + | 'dirty' + | 'message_required' + | 'no_upstream' + | 'needs_push' + | 'force_push' + +export type CreateReviewIntentEligibility = { + eligible: boolean + kind: CreateReviewIntentKind | null +} + +export function resolveCreateReviewIntentEligibility({ + stagedCount, + hasStageableChanges, + hasMessage, + hasUnresolvedConflicts, + upstreamStatus, + hostedReviewCreation, + branchCommitsAhead, + hasCurrentBranch = true +}: { + stagedCount: number + hasStageableChanges: boolean + hasMessage: boolean + hasUnresolvedConflicts: boolean + upstreamStatus: GitUpstreamStatus | undefined + hostedReviewCreation?: HostedReviewCreationEligibility | null + branchCommitsAhead?: number + hasCurrentBranch?: boolean +}): CreateReviewIntentEligibility { + if ( + hasUnresolvedConflicts || + !hasCurrentBranch || + !hostedReviewCreation || + hostedReviewCreation.canCreate || + !supportsHostedReviewCreation(hostedReviewCreation.provider) + ) { + return { eligible: false, kind: null } + } + + if (hostedReviewCreation.blockedReason === 'dirty') { + if (stagedCount > 0 && !hasMessage) { + return { eligible: true, kind: 'message_required' } + } + return { eligible: stagedCount > 0 || hasStageableChanges, kind: 'dirty' } + } + + if (hostedReviewCreation.blockedReason === 'no_upstream') { + const hasPublishableCommits = branchCommitsAhead === undefined ? false : branchCommitsAhead > 0 + return { + eligible: hasPublishableCommits || stagedCount > 0 || hasStageableChanges, + kind: 'no_upstream' + } + } + + if (hostedReviewCreation.blockedReason === 'needs_push') { + return { eligible: true, kind: 'needs_push' } + } + + if ( + hostedReviewCreation.blockedReason === 'needs_sync' && + shouldForcePushWithLeaseForUpstream(upstreamStatus) + ) { + return { eligible: true, kind: 'force_push' } + } + + return { eligible: false, kind: null } +} + +export function resolveVisibleCreateReviewHeaderAction({ + createPrHeaderAction +}: { + createPrHeaderAction: SourceControlPrimaryActionDecision | null +}): SourceControlPrimaryActionDecision | null { + // Why: keep a stable header anchor; disable Create Review when the branch is + // not ready instead of hiding it and shifting toolbar layout. + return createPrHeaderAction +} diff --git a/src/shared/source-control-primary-action-decision-types.ts b/src/shared/source-control-primary-action-decision-types.ts new file mode 100644 index 00000000000..1769dbe0958 --- /dev/null +++ b/src/shared/source-control-primary-action-decision-types.ts @@ -0,0 +1,82 @@ +import type { HostedReviewCreationEligibility } from './hosted-review' +import type { GitUpstreamStatus } from './git-status-types' +import type { PRState } from './types' + +export type SourceControlPrimaryActionKind = + | 'commit' + | 'stage' + | 'push' + | 'pull' + | 'sync' + | 'publish' + | 'create_pr_intent' + | 'create_pr' + +export type SourceControlRemoteOpKind = + | 'push' + | 'force_push' + | 'pull' + | 'sync' + | 'fetch' + | 'fast_forward' + | 'publish' + | 'rebase' + +export type SourceControlPrimaryActionTitleIntent = + | 'commit_in_progress' + | 'force_push_in_progress' + | 'action_in_progress' + | 'remote_operation_in_progress' + | 'remote_operation_blocks_commit' + | 'resolve_conflicts_before_commit' + | 'prepare_review' + | 'commit_staged_changes' + | 'enter_commit_message' + | 'stage_all_changes' + | 'stage_file_to_commit' + | 'checkout_branch_before_publish' + | 'checking_review_status' + | 'review_already_merged' + | 'publish_branch' + | 'push_linked_review' + | 'linked_review_target_unavailable' + | 'force_push_with_lease' + | 'sync_counts' + | 'pull_count' + | 'push_count' + | 'create_review' + | 'nothing_to_commit_up_to_date' + | 'checking_review_creation' + +export type SourceControlPrimaryActionDecision = { + kind: SourceControlPrimaryActionKind + disabled: boolean + labelIntent: SourceControlPrimaryActionKind | 'force_push' + titleIntent: SourceControlPrimaryActionTitleIntent + count?: number + ahead?: number + behind?: number + upstreamName?: string + requiresForceWithLease?: boolean +} + +export type SourceControlPrimaryActionDecisionInputs = { + stagedCount: number + hasUnstagedChanges: boolean + hasStageableChanges: boolean + hasPartiallyStagedChanges: boolean + hasMessage: boolean + hasUnresolvedConflicts: boolean + isCommitting: boolean + isRemoteOperationActive: boolean + upstreamStatus: GitUpstreamStatus | undefined + prState?: PRState | null + isPRStateLoading?: boolean + inFlightRemoteOpKind?: SourceControlRemoteOpKind | null + hostedReviewCreation?: HostedReviewCreationEligibility | null + branchCommitsAhead?: number + hasCurrentBranch?: boolean + canPushLinkedReviewWithoutUpstream?: boolean + isPrIntentInFlight?: boolean + isHostedReviewCreationLoading?: boolean +} diff --git a/src/shared/source-control-primary-action-decision.test.ts b/src/shared/source-control-primary-action-decision.test.ts new file mode 100644 index 00000000000..dad1ee40e3f --- /dev/null +++ b/src/shared/source-control-primary-action-decision.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import { + resolveSourceControlCommitAreaPrimaryActionDecision, + resolveSourceControlPrimaryActionDecision, + type SourceControlPrimaryActionDecisionInputs +} from './source-control-primary-action-decision' + +function inputs( + overrides: Partial = {} +): SourceControlPrimaryActionDecisionInputs { + return { + stagedCount: 0, + hasUnstagedChanges: false, + hasStageableChanges: false, + hasPartiallyStagedChanges: false, + hasMessage: false, + hasUnresolvedConflicts: false, + isCommitting: false, + isRemoteOperationActive: false, + upstreamStatus: undefined, + ...overrides + } +} + +describe('source-control primary action decision', () => { + it('returns Stage All for a dirty tree before remote actions', () => { + const result = resolveSourceControlCommitAreaPrimaryActionDecision( + inputs({ + hasUnstagedChanges: true, + hasStageableChanges: true, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 4 } + }) + ) + expect(result.kind).toBe('stage') + expect(result.titleIntent).toBe('stage_all_changes') + expect(result.disabled).toBe(false) + }) + + it('returns enabled Commit for staged changes with a message', () => { + const result = resolveSourceControlCommitAreaPrimaryActionDecision( + inputs({ stagedCount: 2, hasMessage: true }) + ) + expect(result).toMatchObject({ + kind: 'commit', + titleIntent: 'commit_staged_changes', + disabled: false + }) + }) + + it('blocks commits while unresolved conflicts exist', () => { + const result = resolveSourceControlCommitAreaPrimaryActionDecision( + inputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true }) + ) + expect(result).toMatchObject({ + kind: 'commit', + titleIntent: 'resolve_conflicts_before_commit', + disabled: true + }) + }) + + it('returns remote push, pull, and sync decisions for clean tracked branches', () => { + expect( + resolveSourceControlCommitAreaPrimaryActionDecision( + inputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } }) + ) + ).toMatchObject({ kind: 'push', titleIntent: 'push_count', count: 2 }) + expect( + resolveSourceControlCommitAreaPrimaryActionDecision( + inputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } }) + ) + ).toMatchObject({ kind: 'pull', titleIntent: 'pull_count', count: 3 }) + expect( + resolveSourceControlCommitAreaPrimaryActionDecision( + inputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } }) + ) + ).toMatchObject({ kind: 'sync', titleIntent: 'sync_counts', ahead: 2, behind: 3 }) + }) + + it('returns Publish Branch for clean unpublished branches with a current branch', () => { + const result = resolveSourceControlCommitAreaPrimaryActionDecision( + inputs({ + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, + hasCurrentBranch: true + }) + ) + expect(result).toMatchObject({ + kind: 'publish', + titleIntent: 'publish_branch', + disabled: false + }) + }) + + it('blocks unpublished branch publishing when HEAD is detached', () => { + const result = resolveSourceControlCommitAreaPrimaryActionDecision( + inputs({ + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, + hasCurrentBranch: false + }) + ) + expect(result).toMatchObject({ + kind: 'commit', + titleIntent: 'checkout_branch_before_publish', + disabled: true + }) + }) + + it('mirrors in-flight remote operation semantics', () => { + const result = resolveSourceControlCommitAreaPrimaryActionDecision( + inputs({ + isRemoteOperationActive: true, + inFlightRemoteOpKind: 'pull', + upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 } + }) + ) + expect(result).toMatchObject({ + kind: 'pull', + labelIntent: 'pull', + titleIntent: 'action_in_progress', + disabled: true + }) + }) + + it('marks patch-equivalent diverged branches as force-push-with-lease decisions', () => { + const result = resolveSourceControlCommitAreaPrimaryActionDecision( + inputs({ + branchCommitsAhead: 4, + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 14, + behind: 3, + behindCommitsArePatchEquivalent: true + } + }) + ) + expect(result).toMatchObject({ + kind: 'push', + labelIntent: 'force_push', + titleIntent: 'force_push_with_lease', + requiresForceWithLease: true, + count: 4, + upstreamName: 'origin/feature' + }) + }) + + it('keeps review creation out of commit-area decisions', () => { + const input = inputs({ + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + provider: 'gitlab', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null + } + }) + expect(resolveSourceControlPrimaryActionDecision(input).kind).toBe('create_pr') + expect(resolveSourceControlCommitAreaPrimaryActionDecision(input).kind).toBe('commit') + }) +}) diff --git a/src/shared/source-control-primary-action-decision.ts b/src/shared/source-control-primary-action-decision.ts new file mode 100644 index 00000000000..e51cbdd2e05 --- /dev/null +++ b/src/shared/source-control-primary-action-decision.ts @@ -0,0 +1,252 @@ +import { shouldForcePushWithLeaseForUpstream } from './git-upstream-status' +import { resolveCreateReviewIntentEligibility } from './source-control-create-review-intent' +import { resolveSourceControlPrimaryActionDuringRemoteOp } from './source-control-primary-action-in-flight' +import type { + SourceControlPrimaryActionDecision, + SourceControlPrimaryActionDecisionInputs +} from './source-control-primary-action-decision-types' +import { resolveUnpublishedSourceControlPrimaryAction } from './source-control-primary-unpublished-action' + +export type { + SourceControlPrimaryActionKind, + SourceControlRemoteOpKind, + SourceControlPrimaryActionDecision, + SourceControlPrimaryActionDecisionInputs +} from './source-control-primary-action-decision-types' + +export function resolveSourceControlPrimaryActionDecision( + inputs: SourceControlPrimaryActionDecisionInputs +): SourceControlPrimaryActionDecision { + const { + stagedCount, + hasUnstagedChanges, + hasStageableChanges, + hasMessage, + hasUnresolvedConflicts, + isCommitting, + isRemoteOperationActive, + upstreamStatus, + prState, + isPRStateLoading, + hostedReviewCreation, + branchCommitsAhead, + hasCurrentBranch = true, + canPushLinkedReviewWithoutUpstream = false, + isPrIntentInFlight = false + } = inputs + + if (isPrIntentInFlight) { + return { + kind: 'create_pr_intent', + labelIntent: 'create_pr', + titleIntent: 'prepare_review', + disabled: true + } + } + + if (isCommitting) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'commit_in_progress', + disabled: true + } + } + + if (isRemoteOperationActive) { + return resolveSourceControlPrimaryActionDuringRemoteOp( + inputs, + resolveSourceControlPrimaryActionDecision + ) + } + + if (hasUnresolvedConflicts) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'resolve_conflicts_before_commit', + disabled: true + } + } + + const createPrIntent = resolveCreatePrIntentDecision(inputs) + if (createPrIntent) { + return createPrIntent + } + + const hasStaged = stagedCount > 0 + const hasOpenHostedReview = prState === 'open' || prState === 'draft' + + if (hasStaged && hasMessage) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'commit_staged_changes', + disabled: false + } + } + + if (hasStaged && !hasMessage) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'enter_commit_message', + disabled: true + } + } + + if (!hasStaged && hasStageableChanges) { + return { + kind: 'stage', + labelIntent: 'stage', + titleIntent: 'stage_all_changes', + disabled: false + } + } + + if (!upstreamStatus) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'stage_file_to_commit', + disabled: true + } + } + + if (!upstreamStatus.hasUpstream) { + const unpublishedAction = resolveUnpublishedSourceControlPrimaryAction({ + hasCurrentBranch, + isPRStateLoading, + prState + }) + + if (unpublishedAction.kind === 'publish') { + const linkedReviewAction = resolveLinkedReviewSourceControlPrimaryAction({ + hasOpenHostedReview, + canPushLinkedReviewWithoutUpstream + }) + if (linkedReviewAction) { + return linkedReviewAction + } + } + + return unpublishedAction + } + + if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) { + if (shouldForcePushWithLeaseForUpstream(upstreamStatus)) { + return { + kind: 'push', + labelIntent: 'force_push', + titleIntent: 'force_push_with_lease', + disabled: false, + count: branchCommitsAhead, + upstreamName: upstreamStatus.upstreamName, + requiresForceWithLease: true + } + } + return { + kind: 'sync', + labelIntent: 'sync', + titleIntent: 'sync_counts', + disabled: false, + ahead: upstreamStatus.ahead, + behind: upstreamStatus.behind + } + } + + if (upstreamStatus.behind > 0) { + return { + kind: 'pull', + labelIntent: 'pull', + titleIntent: 'pull_count', + disabled: false, + count: upstreamStatus.behind + } + } + + if (upstreamStatus.ahead > 0) { + return { + kind: 'push', + labelIntent: 'push', + titleIntent: 'push_count', + disabled: false, + count: upstreamStatus.ahead + } + } + + if (hostedReviewCreation?.canCreate) { + return { + kind: 'create_pr', + labelIntent: 'create_pr', + titleIntent: 'create_review', + disabled: false + } + } + + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date', + disabled: true + } +} + +export function resolveSourceControlCommitAreaPrimaryActionDecision( + inputs: SourceControlPrimaryActionDecisionInputs +): SourceControlPrimaryActionDecision { + // Why: review creation is additive chrome. Commit/mobile bottom areas keep + // the local/remote action they would have without review eligibility. + return resolveSourceControlPrimaryActionDecision({ + ...inputs, + hostedReviewCreation: null, + isPrIntentInFlight: false + }) +} + +function resolveCreatePrIntentDecision( + inputs: SourceControlPrimaryActionDecisionInputs +): SourceControlPrimaryActionDecision | null { + const createPrIntent = resolveCreateReviewIntentEligibility({ + stagedCount: inputs.stagedCount, + hasStageableChanges: inputs.hasStageableChanges, + hasMessage: inputs.hasMessage, + hasUnresolvedConflicts: inputs.hasUnresolvedConflicts, + upstreamStatus: inputs.upstreamStatus, + hostedReviewCreation: inputs.hostedReviewCreation, + branchCommitsAhead: inputs.branchCommitsAhead, + hasCurrentBranch: inputs.hasCurrentBranch + }) + if (!createPrIntent.eligible) { + return null + } + return { + kind: 'create_pr_intent', + labelIntent: 'create_pr', + titleIntent: 'prepare_review', + disabled: false + } +} + +function resolveLinkedReviewSourceControlPrimaryAction(args: { + hasOpenHostedReview: boolean + canPushLinkedReviewWithoutUpstream: boolean +}): SourceControlPrimaryActionDecision | null { + if (!args.hasOpenHostedReview) { + return null + } + if (args.canPushLinkedReviewWithoutUpstream) { + return { + kind: 'push', + labelIntent: 'push', + titleIntent: 'push_linked_review', + disabled: false + } + } + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'linked_review_target_unavailable', + disabled: true + } +} diff --git a/src/shared/source-control-primary-action-in-flight.ts b/src/shared/source-control-primary-action-in-flight.ts new file mode 100644 index 00000000000..55d06935e4c --- /dev/null +++ b/src/shared/source-control-primary-action-in-flight.ts @@ -0,0 +1,50 @@ +import type { + SourceControlPrimaryActionDecision, + SourceControlPrimaryActionDecisionInputs +} from './source-control-primary-action-decision-types' + +export function resolveSourceControlPrimaryActionDuringRemoteOp( + inputs: SourceControlPrimaryActionDecisionInputs, + resolveWithoutRemoteOp: ( + inputs: SourceControlPrimaryActionDecisionInputs + ) => SourceControlPrimaryActionDecision +): SourceControlPrimaryActionDecision { + const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs + const candidate = resolveWithoutRemoteOp({ ...inputs, isRemoteOperationActive: false }) + const inFlightIsPrimaryKind = + inFlightRemoteOpKind === 'push' || + inFlightRemoteOpKind === 'pull' || + inFlightRemoteOpKind === 'sync' || + inFlightRemoteOpKind === 'publish' + + if (inFlightRemoteOpKind === 'force_push') { + return { + kind: 'push', + labelIntent: 'force_push', + titleIntent: 'force_push_in_progress', + disabled: true, + requiresForceWithLease: true + } + } + + if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) { + return { + kind: inFlightRemoteOpKind, + labelIntent: inFlightRemoteOpKind, + titleIntent: 'action_in_progress', + disabled: true + } + } + + const titleIntent = hasUnresolvedConflicts + ? 'resolve_conflicts_before_commit' + : candidate.kind === 'commit' + ? 'remote_operation_blocks_commit' + : 'remote_operation_in_progress' + + return { + ...candidate, + titleIntent, + disabled: true + } +} diff --git a/src/shared/source-control-primary-unpublished-action.ts b/src/shared/source-control-primary-unpublished-action.ts new file mode 100644 index 00000000000..ccc71c321c0 --- /dev/null +++ b/src/shared/source-control-primary-unpublished-action.ts @@ -0,0 +1,47 @@ +import type { + SourceControlPrimaryActionDecision, + SourceControlPrimaryActionDecisionInputs +} from './source-control-primary-action-decision-types' + +export function resolveUnpublishedSourceControlPrimaryAction({ + hasCurrentBranch, + isPRStateLoading, + prState +}: Pick< + SourceControlPrimaryActionDecisionInputs, + 'hasCurrentBranch' | 'isPRStateLoading' | 'prState' +>): SourceControlPrimaryActionDecision { + if (!hasCurrentBranch) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'checkout_branch_before_publish', + disabled: true + } + } + + if (isPRStateLoading) { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'checking_review_status', + disabled: true + } + } + + if (prState === 'merged') { + return { + kind: 'commit', + labelIntent: 'commit', + titleIntent: 'review_already_merged', + disabled: true + } + } + + return { + kind: 'publish', + labelIntent: 'publish', + titleIntent: 'publish_branch', + disabled: false + } +}