mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
fix(mobile): one bottom action on the PR tab, and conflicts said once (#37231)
The Source Control hub's next-step bar is now the only bottom action on the PR tab: the tab body takes its footer from the caller, and only the diff-review dock passes the old bottom action. The legacy Conflicts box is gone, since the gate row and the bar's Resolve already cover it, so a conflicting PR no longer says so four times. The bar keeps its reason line only where its button does not name the blocker.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { ActivityIndicator, ScrollView, Text, View } from 'react-native'
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -17,14 +17,12 @@ import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import type { PrSidebarData } from '../session/mobile-pr-sidebar-state'
|
||||
import { isPrSidebarDetailsPlaceholder } from '../session/mobile-pr-sidebar-state'
|
||||
import { buildResolveConflictsPrompt } from '../session/pr-ai-triage-prompt'
|
||||
import { prSidebarRenderBranch } from './mobile-pr-sidebar-presentation'
|
||||
import { ListGroup, ListRow, LIST_LEADING_INSET } from './list/ListGroup'
|
||||
import { useNow } from '../hooks/use-now'
|
||||
import { commentRelativeTime } from './pr-sidebar/pr-comment-relative-time'
|
||||
import { prStateBadge } from './pr-sidebar/pr-checks-presentation'
|
||||
import { statusColor } from './pr-sidebar/pr-sidebar-status-color'
|
||||
import { PRConflictingFilesSection } from './pr-sidebar/PRConflictingFilesSection'
|
||||
import { PrTabEmptyState } from './pr-sidebar/PrTabEmptyState'
|
||||
import { PrChangesCard, PrDescription } from './pr-sidebar/PrTabOverviewCards'
|
||||
import { PrTabBottomAction } from './pr-sidebar/PrTabBottomAction'
|
||||
@@ -53,8 +51,8 @@ type BodyProps = {
|
||||
/** Opens the Changes segment from the changes card; absent outside the hub. */
|
||||
onOpenChanges?: () => void
|
||||
branchCompare?: BranchCompare
|
||||
/** The bottom action until the hub's next-step bar replaces it. */
|
||||
showBottomAction?: boolean
|
||||
/** The dock's bottom action. The hub passes none: its next-step bar is the one bottom action there. */
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
// The PR tab answers "can I merge, and what's blocking it": identity, gates, changes, description.
|
||||
@@ -64,7 +62,7 @@ export function MobilePrTabBody({
|
||||
bottomInset = 0,
|
||||
onOpenChanges,
|
||||
branchCompare = null,
|
||||
showBottomAction = true
|
||||
footer = null
|
||||
}: BodyProps) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
@@ -100,7 +98,6 @@ export function MobilePrTabBody({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const footer = showBottomAction ? <PrTabBottomAction tab={tab} bottomInset={bottomInset} /> : null
|
||||
if (branch === 'none') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -150,20 +147,6 @@ function PrTabSections({
|
||||
}),
|
||||
[data.checks, data.checksError, data.details, pr, tab.botAuthorOverrides]
|
||||
)
|
||||
const conflictsTriage = {
|
||||
resolveConflicts: () =>
|
||||
void tab.triage.launch('resolve-conflicts', () =>
|
||||
buildResolveConflictsPrompt({
|
||||
prNumber: pr.number,
|
||||
baseRef: pr.conflictSummary?.baseRef ?? pr.baseRefName ?? null,
|
||||
files: pr.conflictSummary?.files ?? [],
|
||||
worktreeId: tab.worktreeId
|
||||
})
|
||||
),
|
||||
isBusy: tab.triage.isBusy('resolve-conflicts'),
|
||||
availability: tab.triage.availability,
|
||||
...tab.triage.noticeFor('resolve-conflicts')
|
||||
}
|
||||
const openGate = (row: PrGateRow): (() => void) | undefined => {
|
||||
switch (row.target) {
|
||||
case 'checks':
|
||||
@@ -194,7 +177,6 @@ function PrTabSections({
|
||||
)
|
||||
})}
|
||||
</ListGroup>
|
||||
<PRConflictingFilesSection pr={pr} triage={conflictsTriage} />
|
||||
<PrChangesCard data={data} onPress={onOpenChanges} />
|
||||
<PrDescription tab={tab} data={data} />
|
||||
</>
|
||||
@@ -359,7 +341,12 @@ export function MobilePRSidebar({ onRetry, bottomInset = 0, ...input }: SidebarP
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
<MobilePrTabBody tab={tab} onRetry={onRetry} bottomInset={bottomInset} />
|
||||
<MobilePrTabBody
|
||||
tab={tab}
|
||||
onRetry={onRetry}
|
||||
bottomInset={bottomInset}
|
||||
footer={<PrTabBottomAction tab={tab} bottomInset={bottomInset} />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<MobilePrTabSheets tab={tab} />
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
|
||||
import { PRConflictingFilesSection } from './PRConflictingFilesSection'
|
||||
import { buildMergeabilityRefreshCommands } from './pr-conflict-presentation'
|
||||
|
||||
const clipboard = vi.hoisted(() => ({ writeText: vi.fn() }))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Check: 'Check',
|
||||
Copy: 'Copy',
|
||||
FileWarning: 'FileWarning',
|
||||
Sparkles: 'Sparkles'
|
||||
}))
|
||||
|
||||
vi.mock('../../platform/clipboard', () => ({ useClipboardWriter: () => clipboard }))
|
||||
|
||||
vi.mock('./PRSection', () => ({
|
||||
PRSection: ({ children }: { children: unknown }) => children
|
||||
}))
|
||||
|
||||
// Refresh-command path requires hosted CONFLICTING with no file list but clean local simulation; copying commands resolves that disagreement.
|
||||
const PR: Pick<PRInfo, 'mergeable' | 'conflictSummary'> = {
|
||||
mergeable: 'CONFLICTING',
|
||||
conflictSummary: {
|
||||
files: [],
|
||||
localMergeState: 'clean',
|
||||
commitsBehind: 2,
|
||||
baseCommit: 'abc1234',
|
||||
baseRef: 'main'
|
||||
}
|
||||
}
|
||||
|
||||
const COMMANDS = buildMergeabilityRefreshCommands()
|
||||
|
||||
function press(tree: ReactTestRenderer): Promise<void> {
|
||||
const control = tree.root
|
||||
.findAll((node) => node.props.accessibilityLabel === 'Copy mergeability refresh commands')
|
||||
.at(0)
|
||||
if (!control) {
|
||||
throw new Error('the copy control is not rendered')
|
||||
}
|
||||
return act(async () => control.props.onPress())
|
||||
}
|
||||
|
||||
function labels(tree: ReactTestRenderer): string[] {
|
||||
return tree.root
|
||||
.findAll((node) => typeof node.props.children === 'string')
|
||||
.flatMap((node) => (typeof node.props.children === 'string' ? [node.props.children] : []))
|
||||
}
|
||||
|
||||
describe('copying the mergeability refresh commands', () => {
|
||||
let tree: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
clipboard.writeText.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => tree?.unmount())
|
||||
tree = null
|
||||
})
|
||||
|
||||
async function render(): Promise<ReactTestRenderer> {
|
||||
let rendered: ReactTestRenderer | null = null
|
||||
await act(async () => {
|
||||
rendered = create(createElement(PRConflictingFilesSection, { pr: PR }))
|
||||
})
|
||||
if (rendered === null) {
|
||||
throw new Error('the section did not render')
|
||||
}
|
||||
tree = rendered
|
||||
return rendered
|
||||
}
|
||||
|
||||
it('says it copied when the pasteboard took the commands', async () => {
|
||||
const rendered = await render()
|
||||
await press(rendered)
|
||||
expect(clipboard.writeText).toHaveBeenCalledWith(COMMANDS)
|
||||
expect(labels(rendered)).toContain('Copied')
|
||||
})
|
||||
|
||||
it('says it failed instead of saying nothing at all', async () => {
|
||||
// Expose refused clipboard write; otherwise missing shell grant looks indistinguishable from successful copy.
|
||||
clipboard.writeText.mockRejectedValue(new Error('the clipboard did not accept this text'))
|
||||
const rendered = await render()
|
||||
await press(rendered)
|
||||
expect(labels(rendered)).toContain('Failed to copy text')
|
||||
expect(labels(rendered)).not.toContain('Copied')
|
||||
})
|
||||
})
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'
|
||||
import { Check, Copy, FileWarning, Sparkles } from 'lucide-react-native'
|
||||
import { useClipboardWriter } from '../../platform/clipboard'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
|
||||
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
|
||||
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
|
||||
import { PRSection } from './PRSection'
|
||||
import { resolveConflictDisplay } from './pr-conflict-presentation'
|
||||
import { prConflictStyles as styles } from './pr-conflict-styles'
|
||||
import { prAiTriageStyles as triageStyles } from './pr-ai-triage-styles'
|
||||
import { AgentLaunchNotice } from '../AgentLaunchNotice'
|
||||
import type { MobileAgentLaunchAvailability } from '../../session/mobile-agent-launch-availability'
|
||||
|
||||
// Launches the "Resolve conflicts with AI" agent. Absent for display-only usages.
|
||||
export type PrConflictsTriage = {
|
||||
resolveConflicts: () => void
|
||||
isBusy: boolean
|
||||
availability: MobileAgentLaunchAvailability
|
||||
success: string | null
|
||||
error: string | null
|
||||
warning: string | null
|
||||
undeliveredPrompt: string | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
// Accept conflict view-model inputs only; full PRInfo remains compatible.
|
||||
pr: Pick<PRInfo, 'mergeable' | 'conflictSummary'>
|
||||
// Explain absent file details as loading while refresh is in flight, matching desktop.
|
||||
isRefreshing?: boolean
|
||||
triage?: PrConflictsTriage
|
||||
}
|
||||
|
||||
// Show hosted conflicts as paths or missing-list fallback, matching desktop conflict/notice components.
|
||||
export function PRConflictingFilesSection({ pr, isRefreshing = false, triage }: Props) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
// Use clipboard seam: iOS custom scheme lacks page secure context, while Android https provides one.
|
||||
const clipboard = useClipboardWriter()
|
||||
// Track idle/success/failure: dropping pasteboard refusal used to look like successful copy, unlike Tasks error reporting.
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
|
||||
const copiedResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const conflict = resolveConflictDisplay(pr)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copiedResetTimerRef.current) {
|
||||
clearTimeout(copiedResetTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!conflict) {
|
||||
return null
|
||||
}
|
||||
let noticeBody = t(
|
||||
'pullRequest.conflictingFilesSection.detailsUnavailable',
|
||||
'Conflict file details are unavailable'
|
||||
)
|
||||
if (isRefreshing) {
|
||||
noticeBody = t('pullRequest.conflictingFilesSection.refreshing', 'Refreshing conflict details…')
|
||||
} else if (conflict.localMergeClean) {
|
||||
noticeBody = t(
|
||||
'pullRequest.conflictingFilesSection.localMergeClean',
|
||||
'GitHub reports conflicts, but local Git did not reproduce them. Refresh the PR or push the branch to recalculate mergeability.'
|
||||
)
|
||||
}
|
||||
|
||||
const copyRefreshCommands = async () => {
|
||||
if (!conflict.mergeabilityRefreshCommands) {
|
||||
return
|
||||
}
|
||||
let next: 'copied' | 'failed' = 'copied'
|
||||
try {
|
||||
await clipboard.writeText(conflict.mergeabilityRefreshCommands)
|
||||
} catch {
|
||||
next = 'failed'
|
||||
}
|
||||
if (copiedResetTimerRef.current) {
|
||||
clearTimeout(copiedResetTimerRef.current)
|
||||
}
|
||||
setCopyState(next)
|
||||
copiedResetTimerRef.current = setTimeout(() => {
|
||||
copiedResetTimerRef.current = null
|
||||
setCopyState('idle')
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const copyLabel =
|
||||
copyState === 'copied'
|
||||
? t('common.copied', 'Copied')
|
||||
: copyState === 'failed'
|
||||
? t('pullRequest.conflictingFilesSection.copyFailed', 'Failed to copy text')
|
||||
: t('pullRequest.conflictingFilesSection.copyCommands', 'Copy commands')
|
||||
const count = conflict.commitsBehind
|
||||
// Preserve the commit's styling when a translation moves its placeholder.
|
||||
const behindParts =
|
||||
count !== null && conflict.baseCommit !== null
|
||||
? (count === 1
|
||||
? t(
|
||||
'pullRequest.conflictingFilesSection.commitsBehind_one',
|
||||
'{{count}} commit behind (base commit: {{baseCommit}})',
|
||||
{ count, baseCommit: '\uFFFC' }
|
||||
)
|
||||
: t(
|
||||
'pullRequest.conflictingFilesSection.commitsBehind_other',
|
||||
'{{count}} commits behind (base commit: {{baseCommit}})',
|
||||
{ count, baseCommit: '\uFFFC' }
|
||||
)
|
||||
).split('\uFFFC')
|
||||
: null
|
||||
|
||||
return (
|
||||
<PRSection title={t('pullRequest.conflictingFilesSection.title', 'Conflicts')}>
|
||||
{behindParts ? (
|
||||
<Text style={styles.meta}>
|
||||
{behindParts[0]}
|
||||
<Text style={styles.metaMono}>{conflict.baseCommit}</Text>
|
||||
{behindParts.slice(1).join('')}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{conflict.fileDetailsUnavailable ? (
|
||||
<View>
|
||||
<Text style={styles.noticeTitle}>
|
||||
{t(
|
||||
'pullRequest.conflictingFilesSection.mustResolve',
|
||||
'This branch has conflicts that must be resolved'
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.noticeBody}>{noticeBody}</Text>
|
||||
{conflict.mergeabilityRefreshCommands ? (
|
||||
<View style={styles.commandBox}>
|
||||
<View style={styles.commandHeader}>
|
||||
<Text style={styles.commandLabel}>
|
||||
{t(
|
||||
'pullRequest.conflictingFilesSection.runFromWorktree',
|
||||
'Run from this worktree'
|
||||
)}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.copyCommandButton,
|
||||
pressed && styles.copyCommandButtonPressed
|
||||
]}
|
||||
onPress={() => void copyRefreshCommands()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t(
|
||||
'pullRequest.conflictingFilesSection.copyRefreshCommands',
|
||||
'Copy mergeability refresh commands'
|
||||
)}
|
||||
>
|
||||
{copyState === 'copied' ? (
|
||||
<Check size={13} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
) : (
|
||||
<Copy size={13} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
)}
|
||||
<Text style={styles.copyCommandText}>{copyLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text selectable style={styles.commandText}>
|
||||
{conflict.mergeabilityRefreshCommands}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<View style={styles.filesHeader}>
|
||||
<FileWarning size={14} color={colors.textSecondary} strokeWidth={2} />
|
||||
<Text style={styles.filesHeaderText}>
|
||||
{t('pullRequest.conflictingFilesSection.filesHeading', 'Conflicting files')}
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.fileList}
|
||||
contentContainerStyle={styles.fileListContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{conflict.files.map((filePath) => (
|
||||
<View key={filePath} style={styles.fileRow}>
|
||||
<Text style={styles.filePath}>{filePath}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Launch desktop-style conflict agent to bring base branch in and complete merge. */}
|
||||
{triage ? (
|
||||
<View style={triageStyles.triageArea}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
triageStyles.triageButton,
|
||||
pressed && triageStyles.triageButtonPressed
|
||||
]}
|
||||
onPress={triage.resolveConflicts}
|
||||
disabled={triage.isBusy || triage.availability !== 'available'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t(
|
||||
'pullRequest.conflictingFilesSection.resolveWithAi',
|
||||
'Resolve conflicts with AI'
|
||||
)}
|
||||
>
|
||||
{triage.isBusy ? (
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
) : (
|
||||
<Sparkles size={14} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
)}
|
||||
<Text style={triageStyles.triageButtonText}>
|
||||
{t('pullRequest.conflictingFilesSection.resolveWithAi', 'Resolve conflicts with AI')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<AgentLaunchNotice
|
||||
availability={triage.availability}
|
||||
success={triage.success}
|
||||
error={triage.error}
|
||||
warning={triage.warning}
|
||||
undeliveredPrompt={triage.undeliveredPrompt}
|
||||
errorStyle={triageStyles.triageError}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</PRSection>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
|
||||
import {
|
||||
buildMergeabilityRefreshCommands,
|
||||
resolveConflictDisplay
|
||||
} from './pr-conflict-presentation'
|
||||
|
||||
function pr(over: Partial<PRInfo>): PRInfo {
|
||||
return {
|
||||
number: 1,
|
||||
title: 't',
|
||||
state: 'open',
|
||||
url: '',
|
||||
checksStatus: 'success',
|
||||
updatedAt: '',
|
||||
mergeable: 'MERGEABLE',
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveConflictDisplay', () => {
|
||||
it('builds safe mergeability refresh commands', () => {
|
||||
expect(buildMergeabilityRefreshCommands()).toBe(
|
||||
[
|
||||
'git fetch origin',
|
||||
'git commit --allow-empty --only -m "chore: refresh PR mergeability"',
|
||||
'git push'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when there are no conflicts', () => {
|
||||
expect(resolveConflictDisplay(pr({ mergeable: 'MERGEABLE' }))).toBeNull()
|
||||
expect(resolveConflictDisplay(pr({ mergeable: 'UNKNOWN' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('lists conflicting files with commit metadata', () => {
|
||||
const display = resolveConflictDisplay(
|
||||
pr({
|
||||
mergeable: 'CONFLICTING',
|
||||
conflictSummary: {
|
||||
baseRef: 'main',
|
||||
baseCommit: 'abc1234',
|
||||
commitsBehind: 3,
|
||||
files: ['src/a.ts', 'src/b.ts']
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(display).toEqual({
|
||||
files: ['src/a.ts', 'src/b.ts'],
|
||||
commitsBehind: 3,
|
||||
baseCommit: 'abc1234',
|
||||
fileDetailsUnavailable: false,
|
||||
localMergeClean: false,
|
||||
mergeabilityRefreshCommands: null
|
||||
})
|
||||
})
|
||||
|
||||
it('flags file-details-unavailable when conflicting but no file list', () => {
|
||||
const display = resolveConflictDisplay(pr({ mergeable: 'CONFLICTING' }))
|
||||
expect(display).toEqual({
|
||||
files: [],
|
||||
commitsBehind: null,
|
||||
baseCommit: null,
|
||||
fileDetailsUnavailable: true,
|
||||
localMergeClean: false,
|
||||
mergeabilityRefreshCommands: null
|
||||
})
|
||||
})
|
||||
|
||||
it('flags unavailable when conflictSummary has an empty file list', () => {
|
||||
const display = resolveConflictDisplay(
|
||||
pr({
|
||||
mergeable: 'CONFLICTING',
|
||||
conflictSummary: { baseRef: 'main', baseCommit: 'x', commitsBehind: 0, files: [] }
|
||||
})
|
||||
)
|
||||
expect(display?.fileDetailsUnavailable).toBe(true)
|
||||
})
|
||||
|
||||
it('flags locally clean when GitHub reports a conflict that local git does not reproduce', () => {
|
||||
const display = resolveConflictDisplay(
|
||||
pr({
|
||||
mergeable: 'CONFLICTING',
|
||||
conflictSummary: {
|
||||
baseRef: 'main',
|
||||
baseCommit: 'x',
|
||||
commitsBehind: 1,
|
||||
files: [],
|
||||
localMergeState: 'clean'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(display?.localMergeClean).toBe(true)
|
||||
expect(display?.mergeabilityRefreshCommands).toContain('git fetch origin')
|
||||
expect(display?.mergeabilityRefreshCommands).toContain('git commit --allow-empty --only')
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
|
||||
|
||||
// Keep conflict view-model pure and RN-free; follow desktop ConflictingFilesSection/MergeConflictNotice logic.
|
||||
|
||||
export type ConflictDisplay = {
|
||||
// Host may report conflicts before file list becomes available.
|
||||
files: string[]
|
||||
commitsBehind: number | null
|
||||
baseCommit: string | null
|
||||
// Missing-list conflicts need desktop-style fallback notice.
|
||||
fileDetailsUnavailable: boolean
|
||||
// True when the host reports conflicts but the local merge simulation is clean.
|
||||
localMergeClean: boolean
|
||||
mergeabilityRefreshCommands: string | null
|
||||
}
|
||||
|
||||
// Render only hosted CONFLICTING; MERGEABLE/UNKNOWN show no section, matching desktop.
|
||||
export function hasMergeConflicts(pr: Pick<PRInfo, 'mergeable'>): boolean {
|
||||
return pr.mergeable === 'CONFLICTING'
|
||||
}
|
||||
|
||||
export function buildMergeabilityRefreshCommands(): string {
|
||||
return [
|
||||
'git fetch origin',
|
||||
'git commit --allow-empty --only -m "chore: refresh PR mergeability"',
|
||||
'git push'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// Return conflict model for both listed and missing-file cases so fallback still renders; null when no hosted conflict.
|
||||
export function resolveConflictDisplay(
|
||||
pr: Pick<PRInfo, 'mergeable' | 'conflictSummary'>
|
||||
): ConflictDisplay | null {
|
||||
if (!hasMergeConflicts(pr)) {
|
||||
return null
|
||||
}
|
||||
const files = pr.conflictSummary?.files ?? []
|
||||
const localMergeClean = pr.conflictSummary?.localMergeState === 'clean'
|
||||
return {
|
||||
files,
|
||||
commitsBehind: pr.conflictSummary?.commitsBehind ?? null,
|
||||
baseCommit: pr.conflictSummary?.baseCommit ?? null,
|
||||
fileDetailsUnavailable: files.length === 0,
|
||||
localMergeClean,
|
||||
mergeabilityRefreshCommands: localMergeClean ? buildMergeabilityRefreshCommands() : null
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../../theme/themed-styles'
|
||||
|
||||
// Match desktop conflict section’s muted layout without growing shared sidebar styles.
|
||||
export const prConflictStyles = themedStyleSheet(() => ({
|
||||
meta: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 11
|
||||
},
|
||||
metaMono: {
|
||||
fontFamily: typography.monoFamily,
|
||||
color: colors.textSecondary,
|
||||
fontSize: 11
|
||||
},
|
||||
filesHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
filesHeaderText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 11
|
||||
},
|
||||
// Cap/scroll conflict paths within outer sidebar so large set cannot displace other sections.
|
||||
fileList: {
|
||||
maxHeight: 180,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
fileListContent: {
|
||||
gap: spacing.xs
|
||||
},
|
||||
fileRow: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.surface,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
filePath: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 11,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
noticeTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 11,
|
||||
fontWeight: '600'
|
||||
},
|
||||
noticeBody: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 11,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
commandBox: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.surface,
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.sm
|
||||
},
|
||||
commandHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm
|
||||
},
|
||||
commandLabel: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 10,
|
||||
fontWeight: '600'
|
||||
},
|
||||
copyCommandButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.control,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
copyCommandButtonPressed: {
|
||||
backgroundColor: colors.borderSubtle
|
||||
},
|
||||
copyCommandText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 11,
|
||||
fontWeight: '600'
|
||||
},
|
||||
commandText: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 10,
|
||||
lineHeight: 15,
|
||||
marginTop: spacing.sm
|
||||
}
|
||||
}))
|
||||
@@ -181,10 +181,6 @@
|
||||
"yearsAgo_one": "{{count}}y ago",
|
||||
"yearsAgo_other": "{{count}}y ago"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_one": "{{count}} commit behind (base commit: {{baseCommit}})",
|
||||
"commitsBehind_other": "{{count}} commits behind (base commit: {{baseCommit}})"
|
||||
},
|
||||
"emptyState": {
|
||||
"commitsReady_one": "{{count}} commit on {{branch}} is ready to go into {{base}}.",
|
||||
"commitsReady_other": "{{count}} commits on {{branch}} are ready to go into {{base}}."
|
||||
|
||||
@@ -1525,21 +1525,6 @@
|
||||
"showMore": "{{count}} weitere anzeigen",
|
||||
"title": "Kommentare"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_one": "{{count}} Commit im Rückstand (Basis-Commit: {{baseCommit}})",
|
||||
"commitsBehind_other": "{{count}} Commits im Rückstand (Basis-Commit: {{baseCommit}})",
|
||||
"copyCommands": "Befehle kopieren",
|
||||
"copyFailed": "Text konnte nicht kopiert werden",
|
||||
"copyRefreshCommands": "Befehle zum Aktualisieren der Merge-Fähigkeit kopieren",
|
||||
"detailsUnavailable": "Details zu Dateien mit Konflikten sind nicht verfügbar",
|
||||
"filesHeading": "Dateien mit Konflikten",
|
||||
"localMergeClean": "GitHub meldet Konflikte, die mit lokalem Git nicht reproduziert werden konnten. PR aktualisieren oder Branch pushen, um die Merge-Fähigkeit neu zu berechnen.",
|
||||
"mustResolve": "Dieser Branch enthält Konflikte, die gelöst werden müssen",
|
||||
"refreshing": "Konfliktdetails werden aktualisiert…",
|
||||
"resolveWithAi": "Konflikte mit KI lösen",
|
||||
"runFromWorktree": "Aus diesem Worktree ausführen",
|
||||
"title": "Konflikte"
|
||||
},
|
||||
"linkPrForm": {
|
||||
"linkButton": "Pull Request verknüpfen",
|
||||
"linkNumber": "#{{number}} verknüpfen",
|
||||
|
||||
@@ -1414,21 +1414,6 @@
|
||||
"resolvedCommentCount": "Resolved comment by {{author}} ({{count}})",
|
||||
"commentsLoadFailedRetry": "Could not load comments."
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"detailsUnavailable": "Conflict file details are unavailable",
|
||||
"refreshing": "Refreshing conflict details…",
|
||||
"localMergeClean": "GitHub reports conflicts, but local Git did not reproduce them. Refresh the PR or push the branch to recalculate mergeability.",
|
||||
"copyFailed": "Failed to copy text",
|
||||
"copyCommands": "Copy commands",
|
||||
"commitsBehind_one": "{{count}} commit behind (base commit: {{baseCommit}})",
|
||||
"commitsBehind_other": "{{count}} commits behind (base commit: {{baseCommit}})",
|
||||
"title": "Conflicts",
|
||||
"mustResolve": "This branch has conflicts that must be resolved",
|
||||
"runFromWorktree": "Run from this worktree",
|
||||
"copyRefreshCommands": "Copy mergeability refresh commands",
|
||||
"filesHeading": "Conflicting files",
|
||||
"resolveWithAi": "Resolve conflicts with AI"
|
||||
},
|
||||
"reviewersSection": {
|
||||
"requested": "Requested",
|
||||
"approved": "Approved",
|
||||
|
||||
@@ -1568,22 +1568,6 @@
|
||||
"showMore": "Mostrar {{count}} más",
|
||||
"title": "Comentarios"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_many": "{{count}} commits por detrás (commit base: {{baseCommit}})",
|
||||
"commitsBehind_one": "{{count}} commit por detrás (commit base: {{baseCommit}})",
|
||||
"commitsBehind_other": "{{count}} commits por detrás (commit base: {{baseCommit}})",
|
||||
"copyCommands": "Copiar comandos",
|
||||
"copyFailed": "No se pudo copiar el texto",
|
||||
"copyRefreshCommands": "Copiar comandos para actualizar la capacidad de merge",
|
||||
"detailsUnavailable": "Los detalles del archivo en conflicto no están disponibles",
|
||||
"filesHeading": "Archivos con conflictos",
|
||||
"localMergeClean": "GitHub indica que hay conflictos, pero Git no los ha reproducido en local. Actualiza la PR o haz push de la rama para recalcular la capacidad de merge.",
|
||||
"mustResolve": "Esta rama tiene conflictos que deben resolverse",
|
||||
"refreshing": "Actualizando detalles del conflicto…",
|
||||
"resolveWithAi": "Resolver conflictos con IA",
|
||||
"runFromWorktree": "Ejecutar desde este worktree",
|
||||
"title": "Conflictos"
|
||||
},
|
||||
"linkPrForm": {
|
||||
"linkButton": "Vincular pull request",
|
||||
"linkNumber": "Vincular #{{number}}",
|
||||
|
||||
@@ -1568,22 +1568,6 @@
|
||||
"showMore": "Afficher {{count}} de plus",
|
||||
"title": "Commentaires"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_many": "{{count}} commits de retard (commit de base : {{baseCommit}})",
|
||||
"commitsBehind_one": "{{count}} commit de retard (commit de base : {{baseCommit}})",
|
||||
"commitsBehind_other": "{{count}} commits de retard (commit de base : {{baseCommit}})",
|
||||
"copyCommands": "Copier les commandes",
|
||||
"copyFailed": "Impossible de copier le texte",
|
||||
"copyRefreshCommands": "Copier les commandes de recalcul du merge",
|
||||
"detailsUnavailable": "Les détails du fichier en conflit sont indisponibles",
|
||||
"filesHeading": "Fichiers en conflit",
|
||||
"localMergeClean": "GitHub signale des conflits, mais Git ne les a pas reproduits en local. Actualisez la PR ou poussez la branche pour réévaluer la possibilité de fusion.",
|
||||
"mustResolve": "Cette branche contient des conflits qui doivent être résolus",
|
||||
"refreshing": "Actualisation des détails de conflit…",
|
||||
"resolveWithAi": "Résoudre les conflits avec l'IA",
|
||||
"runFromWorktree": "Exécuter depuis ce worktree",
|
||||
"title": "Conflits"
|
||||
},
|
||||
"linkPrForm": {
|
||||
"linkButton": "Lier la pull request",
|
||||
"linkNumber": "Lier #{{number}}",
|
||||
|
||||
@@ -1521,21 +1521,6 @@
|
||||
"showMore": "さらに {{count}} 件を表示",
|
||||
"title": "コメント"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_one": "{{count}} コミット遅れ(ベースコミット: {{baseCommit}})",
|
||||
"commitsBehind_other": "{{count}} コミット遅れ(ベースコミット: {{baseCommit}})",
|
||||
"copyCommands": "コマンドをコピー",
|
||||
"copyFailed": "テキストのコピーに失敗しました",
|
||||
"copyRefreshCommands": "マージ可否を再取得するコマンドをコピー",
|
||||
"detailsUnavailable": "競合ファイルの詳細は利用できません",
|
||||
"filesHeading": "競合するファイル",
|
||||
"localMergeClean": "GitHub では競合が報告されていますが、ローカルの Git では再現されませんでした。PR を更新するか、ブランチをプッシュしてマージ可否を再計算してください。",
|
||||
"mustResolve": "このブランチには解決する必要のある競合があります",
|
||||
"refreshing": "競合の詳細を更新しています…",
|
||||
"resolveWithAi": "AI で競合を解決",
|
||||
"runFromWorktree": "このワークツリーから実行",
|
||||
"title": "競合"
|
||||
},
|
||||
"linkPrForm": {
|
||||
"linkButton": "PR をリンク",
|
||||
"linkNumber": "#{{number}} をリンク",
|
||||
|
||||
@@ -1521,21 +1521,6 @@
|
||||
"showMore": "{{count}}개 더 보기",
|
||||
"title": "댓글"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_one": "커밋 {{count}}개 뒤처짐 (기준 커밋: {{baseCommit}})",
|
||||
"commitsBehind_other": "커밋 {{count}}개 뒤처짐 (기준 커밋: {{baseCommit}})",
|
||||
"copyCommands": "명령 복사",
|
||||
"copyFailed": "텍스트 복사 실패",
|
||||
"copyRefreshCommands": "병합 가능성 새로 고침 명령 복사",
|
||||
"detailsUnavailable": "충돌 파일 세부정보를 사용할 수 없습니다.",
|
||||
"filesHeading": "충돌하는 파일",
|
||||
"localMergeClean": "GitHub에서는 충돌이 있다고 보고하지만 로컬 Git에서는 충돌이 재현되지 않았습니다. PR을 새로고침하거나 브랜치를 푸시해 병합 가능성을 다시 계산하세요.",
|
||||
"mustResolve": "이 브랜치에는 해결해야 할 충돌이 있습니다.",
|
||||
"refreshing": "충돌 세부정보 새로고침 중…",
|
||||
"resolveWithAi": "AI로 충돌 해결",
|
||||
"runFromWorktree": "이 워크트리에서 실행",
|
||||
"title": "충돌"
|
||||
},
|
||||
"linkPrForm": {
|
||||
"linkButton": "PR 연결",
|
||||
"linkNumber": "#{{number}} 연결",
|
||||
|
||||
@@ -1568,22 +1568,6 @@
|
||||
"showMore": "Mostrar mais {{count}}",
|
||||
"title": "Comentários"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_many": "{{count}} commits atrás (commit base: {{baseCommit}})",
|
||||
"commitsBehind_one": "{{count}} commit atrás (commit base: {{baseCommit}})",
|
||||
"commitsBehind_other": "{{count}} commits atrás (commit base: {{baseCommit}})",
|
||||
"copyCommands": "Copiar comandos",
|
||||
"copyFailed": "Falha ao copiar o texto",
|
||||
"copyRefreshCommands": "Copiar comandos de atualização de possibilidade de merge",
|
||||
"detailsUnavailable": "Os detalhes dos arquivos em conflito não estão disponíveis",
|
||||
"filesHeading": "Arquivos em conflito",
|
||||
"localMergeClean": "O GitHub informa conflitos, mas o Git local não os reproduziu. Atualize o PR ou faça push da branch para recalcular a possibilidade de merge.",
|
||||
"mustResolve": "Esta branch tem conflitos que precisam ser resolvidos",
|
||||
"refreshing": "Atualizando os detalhes do conflito…",
|
||||
"resolveWithAi": "Resolver conflitos com IA",
|
||||
"runFromWorktree": "Executar a partir desta worktree",
|
||||
"title": "Conflitos"
|
||||
},
|
||||
"linkPrForm": {
|
||||
"linkButton": "Vincular pull request",
|
||||
"linkNumber": "Vincular #{{number}}",
|
||||
|
||||
@@ -1611,23 +1611,6 @@
|
||||
"showMore": "Показать ещё {{count}}",
|
||||
"title": "Комментарии"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_few": "Отставание на {{count}} коммита (базовый коммит: {{baseCommit}})",
|
||||
"commitsBehind_many": "Отставание на {{count}} коммитов (базовый коммит: {{baseCommit}})",
|
||||
"commitsBehind_one": "Отставание на {{count}} коммит (базовый коммит: {{baseCommit}})",
|
||||
"commitsBehind_other": "Отставание на {{count}} коммита (базовый коммит: {{baseCommit}})",
|
||||
"copyCommands": "Скопировать команды",
|
||||
"copyFailed": "Не удалось скопировать текст",
|
||||
"copyRefreshCommands": "Скопировать команды для повторной проверки возможности слияния",
|
||||
"detailsUnavailable": "Сведения о конфликтующих файлах недоступны",
|
||||
"filesHeading": "Конфликтующие файлы",
|
||||
"localMergeClean": "GitHub сообщает о конфликтах, но локальный Git их не воспроизвёл. Обновите PR или отправьте ветку, чтобы повторно проверить возможность слияния.",
|
||||
"mustResolve": "В этой ветке есть конфликты, которые нужно разрешить",
|
||||
"refreshing": "Обновление сведений о конфликтах…",
|
||||
"resolveWithAi": "Разрешить конфликты с помощью ИИ",
|
||||
"runFromWorktree": "Выполните в этом рабочем дереве",
|
||||
"title": "Конфликты"
|
||||
},
|
||||
"linkPrForm": {
|
||||
"linkButton": "Привязать пул-реквест",
|
||||
"linkNumber": "Привязать #{{number}}",
|
||||
|
||||
@@ -1525,21 +1525,6 @@
|
||||
"showMore": "{{count}} tane daha göster",
|
||||
"title": "Yorumlar"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_one": "{{count}} commit geride (temel commit: {{baseCommit}})",
|
||||
"commitsBehind_other": "{{count}} commit geride (temel commit: {{baseCommit}})",
|
||||
"copyCommands": "Komutları kopyala",
|
||||
"copyFailed": "Metin kopyalanamadı",
|
||||
"copyRefreshCommands": "Merge edilebilirlik yenileme komutlarını kopyala",
|
||||
"detailsUnavailable": "Conflict'li dosyaların ayrıntıları kullanılamıyor",
|
||||
"filesHeading": "Conflict'li dosyalar",
|
||||
"localMergeClean": "GitHub conflict bildiriyor ama yerel Git bunları yeniden oluşturamadı. Merge edilebilirliği yeniden hesaplamak için PR görünümünü yenileyin veya branch'i push edin.",
|
||||
"mustResolve": "Bu branch'in çözülmesi gereken conflict'leri var",
|
||||
"refreshing": "Conflict ayrıntıları yenileniyor…",
|
||||
"resolveWithAi": "Conflict'leri AI ile çöz",
|
||||
"runFromWorktree": "Bu worktree'den çalıştır",
|
||||
"title": "Conflict'ler"
|
||||
},
|
||||
"linkPrForm": {
|
||||
"linkButton": "Pull request'i bağla",
|
||||
"linkNumber": "Bağla: #{{number}}",
|
||||
|
||||
@@ -1521,21 +1521,6 @@
|
||||
"showMore": "再顯示 {{count}} 則",
|
||||
"title": "留言"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_one": "落後 {{count}} 個提交(基底提交:{{baseCommit}})",
|
||||
"commitsBehind_other": "落後 {{count}} 個提交(基底提交:{{baseCommit}})",
|
||||
"copyCommands": "複製命令",
|
||||
"copyFailed": "無法複製文字",
|
||||
"copyRefreshCommands": "複製重新判斷是否可合併的命令",
|
||||
"detailsUnavailable": "無法取得衝突檔案的詳細資訊",
|
||||
"filesHeading": "衝突的檔案",
|
||||
"localMergeClean": "GitHub 回報有衝突,但本機 Git 未重現這些衝突。請重新整理 PR 或推送分支,以重新判斷是否可合併。",
|
||||
"mustResolve": "此分支有必須解決的衝突",
|
||||
"refreshing": "正在重新整理衝突詳細資訊…",
|
||||
"resolveWithAi": "使用 AI 解決衝突",
|
||||
"runFromWorktree": "從此工作樹執行",
|
||||
"title": "衝突"
|
||||
},
|
||||
"linkPrForm": {
|
||||
"linkButton": "連結 PR",
|
||||
"linkNumber": "連結 #{{number}}",
|
||||
|
||||
@@ -1521,21 +1521,6 @@
|
||||
"showMore": "再显示 {{count}} 条",
|
||||
"title": "评论"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_one": "落后 {{count}} 个提交(基础提交:{{baseCommit}})",
|
||||
"commitsBehind_other": "落后 {{count}} 个提交(基础提交:{{baseCommit}})",
|
||||
"copyCommands": "复制命令",
|
||||
"copyFailed": "复制文本失败",
|
||||
"copyRefreshCommands": "复制可合并性刷新命令",
|
||||
"detailsUnavailable": "冲突文件详细信息不可用",
|
||||
"filesHeading": "冲突的文件",
|
||||
"localMergeClean": "GitHub 提示存在冲突,但本地 Git 未能复现。请刷新 PR 或推送分支,以重新计算可合并性。",
|
||||
"mustResolve": "该分支存在必须解决的冲突",
|
||||
"refreshing": "刷新冲突细节…",
|
||||
"resolveWithAi": "使用 AI 解决冲突",
|
||||
"runFromWorktree": "从此工作树运行",
|
||||
"title": "冲突"
|
||||
},
|
||||
"linkPrForm": {
|
||||
"linkButton": "关联 PR",
|
||||
"linkNumber": "关联 #{{number}}",
|
||||
|
||||
@@ -362,8 +362,6 @@ export function MobileSourceControlPanel({
|
||||
bottomInset={barInset}
|
||||
onOpenChanges={() => selectTab('changes')}
|
||||
branchCompare={branchCompare}
|
||||
// The bar offers Create and the review steps; on a local step Merge stays reachable here.
|
||||
showBottomAction={!nextStep.offersMerge && prTab.state.kind === 'ready'}
|
||||
/>
|
||||
</View>
|
||||
) : activeTab === 'pr' ? (
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SourceControlNextStep } from '../../../src/shared/source-control-next-step-decision'
|
||||
import {
|
||||
buildMobileNextStepBar,
|
||||
type BuildMobileNextStepBarArgs
|
||||
} from './mobile-source-control-next-step'
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
function bar(step: SourceControlNextStep) {
|
||||
const args: BuildMobileNextStepBarArgs = {
|
||||
step,
|
||||
primaryAction: {
|
||||
kind: 'commit',
|
||||
label: 'Commit',
|
||||
accessibilityLabel: 'Commit',
|
||||
accessibilityHint: '',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
onPress: noop
|
||||
},
|
||||
createPrAction: {
|
||||
visible: false,
|
||||
label: 'Create pull request',
|
||||
reviewLabel: 'pull request',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
pushFirst: false,
|
||||
onPress: noop
|
||||
},
|
||||
pr: null,
|
||||
prDetails: null,
|
||||
createBlocked: false,
|
||||
agentName: 'Claude',
|
||||
busy: {
|
||||
completingMerge: false,
|
||||
agentLaunch: false,
|
||||
agentUnavailable: false,
|
||||
merge: false,
|
||||
autoMerge: false,
|
||||
ioBusy: false,
|
||||
prMutationsBlocked: false
|
||||
},
|
||||
handlers: {
|
||||
completeMerge: noop,
|
||||
resolveConflicts: noop,
|
||||
fixChecks: noop,
|
||||
merge: noop,
|
||||
setAutoMerge: noop
|
||||
}
|
||||
}
|
||||
return buildMobileNextStepBar(args)
|
||||
}
|
||||
|
||||
describe('the next-step bar reason line', () => {
|
||||
// The PR tab's gate row already says "Conflicts with main"; the button names the fix, so a reason would repeat it.
|
||||
it.each(['local', 'review'] as const)('leaves Resolve with the agent bare (%s)', (scope) => {
|
||||
const view = bar({ kind: 'resolve_conflicts', scope })
|
||||
expect(view).toMatchObject({ kind: 'button', label: 'Resolve with Claude', reason: null })
|
||||
})
|
||||
|
||||
it('keeps the reason beside Fix, whose label does not say what failed', () => {
|
||||
const view = bar({ kind: 'fix_checks', failedChecks: 2 })
|
||||
expect(view).toMatchObject({ kind: 'button', label: 'Fix with Claude' })
|
||||
expect(view.kind === 'button' ? view.reason?.text : null).toBe('2 checks failing')
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,6 @@ import { getBrokenChecks } from '../../../src/shared/pr-checks-fix-prompt'
|
||||
import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision'
|
||||
import {
|
||||
resolveSourceControlNextStep,
|
||||
type SourceControlNextStep,
|
||||
type SourceControlNextStepReview
|
||||
} from '../../../src/shared/source-control-next-step-decision'
|
||||
import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree/id'
|
||||
@@ -48,16 +47,6 @@ type Params = {
|
||||
onMerge?: () => void
|
||||
}
|
||||
|
||||
// Steps where the bar itself offers the PR's merge actions.
|
||||
// Agent steps leave Merge and auto-merge to the PR body.
|
||||
const REVIEW_STEPS: ReadonlySet<SourceControlNextStep['kind']> = new Set([
|
||||
'merge_when_ready',
|
||||
'auto_merge_on',
|
||||
'merge',
|
||||
'merge_blocked',
|
||||
'merged'
|
||||
])
|
||||
|
||||
export type MobileMergeConfirm = {
|
||||
title: string
|
||||
message: string
|
||||
@@ -288,9 +277,7 @@ export function useMobileSourceControlNextStep({
|
||||
}
|
||||
: null
|
||||
|
||||
// While the bar is on a local step, the PR body keeps its own Merge so it stays reachable.
|
||||
return {
|
||||
offersMerge: REVIEW_STEPS.has(step.kind),
|
||||
view,
|
||||
notice,
|
||||
agentNotice,
|
||||
|
||||
Reference in New Issue
Block a user