Align mobile review actions with desktop (#6444)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-26 17:35:32 -07:00 committed by GitHub
parent 8c81c30f6a
commit e1f93238d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1855 additions and 379 deletions

View File

@ -41,9 +41,9 @@ export function MobileSourceControlContent({ state, hostId, worktreeId, name }:
unstagedCount,
branchLabel,
syncLabel,
primaryAction,
stageAll,
unstageAll,
commit,
generateCommitMessage,
cancelGenerateCommitMessage,
abortConflictOperation,
@ -52,6 +52,7 @@ export function MobileSourceControlContent({ state, hostId, worktreeId, name }:
runGitAction
} = state
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
const shouldShowGenerateButton = stagedCount > 0 || generatingMessage
return (
<>
@ -224,46 +225,50 @@ 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}
/>
)}
<Pressable
style={({ pressed }) => [
styles.generateButton,
(stagedCount === 0 || busyAction !== null) && styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
// Why: stay tappable while generating so the press can cancel
// (disabling it here made the cancel branch below unreachable).
disabled={stagedCount === 0 || busyAction !== null}
onPress={() =>
generatingMessage ? cancelGenerateCommitMessage() : void generateCommitMessage()
}
accessibilityLabel={
generatingMessage
? 'Cancel commit message generation'
: 'Generate commit message with AI'
}
>
{generatingMessage ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Sparkles size={16} color={colors.textSecondary} strokeWidth={2.1} />
)}
</Pressable>
{shouldShowGenerateButton ? (
<Pressable
style={({ pressed }) => [
styles.generateButton,
busyAction !== null && styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
// Why: commit-message AI belongs to the commit path; hiding it
// during Stage All keeps the quick action visually unambiguous.
disabled={busyAction !== null}
onPress={() =>
generatingMessage ? cancelGenerateCommitMessage() : void generateCommitMessage()
}
accessibilityLabel={
generatingMessage
? 'Cancel commit message generation'
: 'Generate commit message with AI'
}
>
{generatingMessage ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Sparkles size={16} color={colors.textSecondary} strokeWidth={2.1} />
)}
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [
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 ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.commitButtonText}>Commit</Text>
<Text style={styles.commitButtonText}>{primaryAction.label}</Text>
)}
</Pressable>
</View>

View File

@ -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
)
}

View File

@ -0,0 +1,271 @@
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> = {}): MobileGitStatusResult {
return {
entries: [],
conflictOperation: 'unknown',
branch: 'feature',
head: 'abc123',
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
...overrides
}
}
function args(overrides: Partial<MobileSourceControlPrimaryActionArgs> = {}) {
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> = {}
): 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 }
})
},
{
name: 'in-flight force push mirrors force push',
input: decisionInputs({
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'force_push',
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
})
},
{
name: 'in-flight push blocks committable candidate',
input: decisionInputs({
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'push',
stagedCount: 1,
hasMessage: true
})
}
])('matches the shared commit-area decision for $name', ({ input }) => {
expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual(
resolveSourceControlCommitAreaPrimaryActionDecision(input)
)
})
})

View File

@ -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<string, unknown> }
export type MobileSourceControlPrimaryAction = {
kind: MobileSourceControlPrimaryActionKind
label: string
accessibilityLabel: string
accessibilityHint: string
disabled: boolean
loading: boolean
requiresForceWithLease?: boolean
onPress: () => void
}
export type MobileSourceControlPrimaryActionHandlers = {
commit: () => Promise<boolean>
stageAll: () => Promise<void>
runActionSheetGitSequence: (actionId: string, steps: GitStep[]) => Promise<void>
runActionSheetGitSync: () => Promise<void>
}
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<void> {
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
}
}

View File

@ -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 {
@ -100,8 +101,9 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const onShow = Keyboard.addListener(showEvent, (event) => {
const height = event.endCoordinates.height - (Platform.OS === 'ios' ? insets.bottom : 0)
setKeyboardLift(Math.max(0, height))
// Why: iOS keyboard height already describes the obscured screen area.
// Subtracting the safe-area inset lets the commit bar tuck under the keyboard.
setKeyboardLift(Math.max(0, event.endCoordinates.height))
})
const onHide = Keyboard.addListener(hideEvent, () => setKeyboardLift(0))
@ -109,7 +111,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
onShow.remove()
onHide.remove()
}
}, [insets.bottom])
}, [])
const status = screenState.kind === 'ready' ? screenState.status : null
const entries = status?.entries ?? []
@ -157,6 +159,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 +206,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 +296,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
upstream,
upstreamKnown,
syncLabel,
primaryAction,
// actions
loadStatus,
openFile,

28
mobile/vitest.config.ts Normal file
View File

@ -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']
}
})

View File

@ -1,78 +1,16 @@
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'
// Why: renderer APIs keep PR terminology for compatibility, while shared logic
// uses provider-neutral review terminology for PR/MR hosts.
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

View File

@ -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

View File

@ -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,209 @@ 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 translate(
'auto.components.right.sidebar.source.control.primary.action.6f7a8b9c0d',
'Remote operation in progress…'
)
case 'remote_operation_blocks_commit':
return translate(
'auto.components.right.sidebar.source.control.primary.action.7f8a9b0c1d',
'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 translate(
'auto.components.right.sidebar.source.control.primary.action.8f9a0b1c2d',
'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 }
)
}
}

View File

@ -9115,6 +9115,8 @@
"2d8f185fbc": "Stage all changes before committing partially staged files",
"a6457b46a7": "Resolve conflicts before committing",
"484f45c439": "{{value0}} in progress…",
"6f7a8b9c0d": "Remote operation in progress…",
"7f8a9b0c1d": "Remote operation in progress — try again once it finishes",
"74fc171e99": "Force Push in progress…",
"16aee3a5c1": "Commit in progress…",
"e61b0d7a3c": "Check out a branch before publishing commits.",
@ -9134,6 +9136,7 @@
"d8a4c0e369": "Authenticate before creating a {{value0}}.",
"e9b5d1f470": "Check out a branch before creating a {{value0}}.",
"f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
"8f9a0b1c2d": "Nothing to commit. Branch is up to date.",
"h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}

View File

@ -9115,6 +9115,8 @@
"2d8f185fbc": "Prepare todos los cambios antes de enviar archivos parcialmente preparados",
"a6457b46a7": "Resolver conflictos antes de comprometerse",
"484f45c439": "{{value0}} en progreso…",
"6f7a8b9c0d": "Remote operation in progress…",
"7f8a9b0c1d": "Remote operation in progress — try again once it finishes",
"74fc171e99": "Empuje forzado en progreso...",
"16aee3a5c1": "Compromiso en progreso...",
"e61b0d7a3c": "Cambia a una rama antes de publicar commits.",
@ -9134,6 +9136,7 @@
"d8a4c0e369": "Authenticate before creating a {{value0}}.",
"e9b5d1f470": "Check out a branch before creating a {{value0}}.",
"f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
"8f9a0b1c2d": "Nothing to commit. Branch is up to date.",
"h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}

View File

@ -9115,6 +9115,8 @@
"2d8f185fbc": "部分的にステージングされたファイルを commit する前に、すべての変更をステージングします。",
"a6457b46a7": "commit する前に競合を解決する",
"484f45c439": "{{value0}} が進行中です…",
"6f7a8b9c0d": "Remote operation in progress…",
"7f8a9b0c1d": "Remote operation in progress — try again once it finishes",
"74fc171e99": "強制プッシュ中です…",
"16aee3a5c1": "Commit 中です…",
"e61b0d7a3c": "commits を公開する前にブランチをチェックアウトしてください。",
@ -9134,6 +9136,7 @@
"d8a4c0e369": "Authenticate before creating a {{value0}}.",
"e9b5d1f470": "Check out a branch before creating a {{value0}}.",
"f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
"8f9a0b1c2d": "Nothing to commit. Branch is up to date.",
"h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}

View File

@ -9115,6 +9115,8 @@
"2d8f185fbc": "부분적으로 준비된 파일을 commit 하기 전에 모든 변경 사항을 준비합니다.",
"a6457b46a7": "commit 하기 전에 충돌을 해결하세요.",
"484f45c439": "{{value0}} 진행 중…",
"6f7a8b9c0d": "Remote operation in progress…",
"7f8a9b0c1d": "Remote operation in progress — try again once it finishes",
"74fc171e99": "강제 푸시 진행 중…",
"16aee3a5c1": "Commit 진행 중…",
"e61b0d7a3c": "commits을 게시하기 전에 브랜치를 체크아웃하세요.",
@ -9134,6 +9136,7 @@
"d8a4c0e369": "{{value0}}을(를) 만들기 전에 인증하세요.",
"e9b5d1f470": "{{value0}}을(를) 만들기 전에 브랜치를 체크아웃하세요.",
"f0c6e2a581": "이 브랜치는 아직 {{value0}}을(를) 만들 준비가 되지 않았습니다.",
"8f9a0b1c2d": "Nothing to commit. Branch is up to date.",
"h3i4j5k607": "이 브랜치에서 {{value0}}을(를) 만들 수 있는지 확인 중…"
}
}

View File

@ -9115,6 +9115,8 @@
"2d8f185fbc": "在提交部分暂存的文件之前暂存所有更改",
"a6457b46a7": "提交前解决冲突",
"484f45c439": "{{value0}} 正在进行中...",
"6f7a8b9c0d": "Remote operation in progress…",
"7f8a9b0c1d": "Remote operation in progress — try again once it finishes",
"74fc171e99": "强制推送正在进行中...",
"16aee3a5c1": "正在进行中……",
"e61b0d7a3c": "请先检出分支再发布 commits。",
@ -9134,6 +9136,7 @@
"d8a4c0e369": "Authenticate before creating a {{value0}}.",
"e9b5d1f470": "Check out a branch before creating a {{value0}}.",
"f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
"8f9a0b1c2d": "Nothing to commit. Branch is up to date.",
"h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest'
import {
resolveSourceControlCommitAreaPrimaryActionDecision,
resolveSourceControlPrimaryActionDecision,
type SourceControlPrimaryActionDecisionInputs
} from './source-control-primary-action-decision'
function inputs(
overrides: Partial<SourceControlPrimaryActionDecisionInputs> = {}
): 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')
})
it('returns disabled create review while hosted-review creation eligibility is loading', () => {
const result = resolveSourceControlPrimaryActionDecision(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: false,
blockedReason: null,
nextAction: null
},
isHostedReviewCreationLoading: true
})
)
expect(result).toMatchObject({
kind: 'create_pr',
titleIntent: 'checking_review_creation',
disabled: true
})
})
})

View File

@ -0,0 +1,279 @@
import { shouldForcePushWithLeaseForUpstream } from './git-upstream-status'
import { supportsHostedReviewCreation } from './hosted-review-creation-providers'
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,
isHostedReviewCreationLoading = 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
}
}
if (
isHostedReviewCreationLoading &&
hostedReviewCreation &&
shouldOfferCreateReviewLoadingAction(hostedReviewCreation)
) {
return {
kind: 'create_pr',
labelIntent: 'create_pr',
titleIntent: 'checking_review_creation',
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
}
}
function shouldOfferCreateReviewLoadingAction(
hostedReviewCreation: SourceControlPrimaryActionDecisionInputs['hostedReviewCreation']
): boolean {
if (!supportsHostedReviewCreation(hostedReviewCreation?.provider)) {
return false
}
return (
hostedReviewCreation.blockedReason !== 'existing_review' &&
hostedReviewCreation.blockedReason !== 'unsupported_provider'
)
}
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
}
}

View File

@ -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
}
}

View File

@ -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
}
}