Align mobile review actions with desktop (#6444)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
8c81c30f6a
commit
e1f93238d1
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}…"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}…"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}…"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}을(를) 만들 수 있는지 확인 중…"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}…"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue