Add mobile committed branch changes (#3498)

This commit is contained in:
Neil 2026-05-30 11:33:30 -07:00 committed by GitHub
parent ef74c713ae
commit fb462e1f77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 722 additions and 23 deletions

View File

@ -27,17 +27,32 @@ import {
MoreHorizontal,
Plus,
RefreshCw,
Trash2
Trash2,
X
} from 'lucide-react-native'
import { useHostClient } from '../../../../src/transport/client-context'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import type { RpcSuccess } from '../../../../src/transport/types'
import {
ActionSheetModal,
type ActionSheetAction
} from '../../../../src/components/ActionSheetModal'
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../../src/components/BottomDrawer'
import { triggerError, triggerSelection, triggerSuccess } from '../../../../src/platform/haptics'
import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
import {
buildMobileDiffLines,
type MobileDiffLine
} from '../../../../src/session/mobile-diff-lines'
import {
buildMobileBranchCompareSection,
canOpenMobileBranchCompareDiff,
formatMobileBranchCompareSummary,
type MobileGitBranchChangeEntry,
type MobileGitBranchCompareResult,
type MobileGitBranchCompareSummary
} from '../../../../src/source-control/mobile-branch-compare'
import {
MOBILE_GIT_STATUS_LABELS,
buildMobileSourceControlSections,
@ -86,6 +101,43 @@ type MobileGitStatusEntryView = MobileGitStatusEntry & {
unstageActionId: string
}
type MobileBranchCompareState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'ready'; result: MobileGitBranchCompareResult }
| { kind: 'error'; message: string }
type MobileBranchEntryView = MobileGitBranchChangeEntry & {
canOpen: boolean
}
type MobileBranchDiffPreviewState =
| { kind: 'loading'; entry: MobileGitBranchChangeEntry }
| {
kind: 'ready'
entry: MobileGitBranchChangeEntry
summary: MobileGitBranchCompareSummary
lines: MobileDiffLine[]
truncated: boolean
}
| { kind: 'error'; entry: MobileGitBranchChangeEntry; message: string }
type RuntimeRepoSummary = {
id: string
worktreeBaseRef?: string | null
}
type RepoBaseRefDefaultResult = {
defaultBaseRef: string | null
remoteCount: number
}
type GitDiffTextResult = {
kind: 'text'
originalContent: string
modifiedContent: string
}
const KEYBOARD_COMMIT_BAR_CLEARANCE = 10
const SELECTOR_RETRY_COUNT = 3
const SELECTOR_RETRY_DELAY_MS = 250
@ -98,6 +150,45 @@ function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getRepoIdFromMobileWorktreeId(id: string): string {
// Why: mobile cannot import desktop shared modules in its standalone tsc run,
// but the runtime worktree id wire format is still `${repoId}::${path}`.
const separatorIdx = id.indexOf('::')
return separatorIdx === -1 ? id : id.slice(0, separatorIdx)
}
async function resolveMobileBranchCompareBaseRef(
client: RpcClient,
worktreeId: string
): Promise<string | null> {
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
if (!repoId) {
return null
}
let repoBaseRef: string | null = null
const repoResponse = await client.sendRequest('repo.list')
if (repoResponse.ok) {
const repos = ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos
const repo = repos?.find((candidate) => candidate.id === repoId)
repoBaseRef = repo?.worktreeBaseRef?.trim() || null
}
if (repoBaseRef) {
return repoBaseRef
}
const defaultResponse = await client.sendRequest('repo.baseRefDefault', { repo: `id:${repoId}` })
if (!defaultResponse.ok) {
if (isMobileGitUnavailable(defaultResponse.error?.code, defaultResponse.error?.message)) {
return null
}
throw new Error(defaultResponse.error?.message || 'Unable to resolve branch base')
}
const result = (defaultResponse as RpcSuccess).result as RepoBaseRefDefaultResult
return result.defaultBaseRef?.trim() || null
}
function getWorktreeLabel(name: string | undefined, worktreeId: string): string {
if (name?.trim()) {
return name.trim()
@ -133,6 +224,27 @@ function statusColor(status: MobileGitFileStatus): string {
}
}
function formatBranchEntryMeta(entry: MobileGitBranchChangeEntry): string | null {
const stats =
entry.added !== undefined || entry.removed !== undefined
? `+${entry.added ?? 0} -${entry.removed ?? 0}`
: null
if (entry.oldPath) {
return stats ? `from ${entry.oldPath}; ${stats}` : `from ${entry.oldPath}`
}
return stats
}
function diffLinePrefix(kind: MobileDiffLine['kind']): string {
if (kind === 'add') return '+'
if (kind === 'delete') return '-'
return ' '
}
function diffLineNumber(line: MobileDiffLine): string {
return String(line.newLineNumber ?? line.oldLineNumber ?? '')
}
export default function MobileSourceControlScreen() {
const params = useLocalSearchParams<{
hostId?: string | string[]
@ -148,6 +260,12 @@ export default function MobileSourceControlScreen() {
const insets = useSafeAreaInsets()
const { client, state: connState } = useHostClient(hostId)
const [screenState, setScreenState] = useState<ScreenState>({ kind: 'loading' })
const [branchCompareState, setBranchCompareState] = useState<MobileBranchCompareState>({
kind: 'idle'
})
const [branchDiffPreview, setBranchDiffPreview] = useState<MobileBranchDiffPreviewState | null>(
null
)
const [busyAction, setBusyAction] = useState<string | null>(null)
const [commitMessage, setCommitMessage] = useState('')
const [discardTarget, setDiscardTarget] = useState<MobileGitStatusEntry | null>(null)
@ -155,20 +273,26 @@ export default function MobileSourceControlScreen() {
const [actionError, setActionError] = useState<string | null>(null)
const [keyboardLift, setKeyboardLift] = useState(0)
const [openingPath, setOpeningPath] = useState<string | null>(null)
const [openingBranchPath, setOpeningBranchPath] = useState<string | null>(null)
const busyActionRef = useRef<string | null>(null)
const currentStatusIdentityRef = useRef('')
const currentBranchCompareIdentityRef = useRef('')
const loadGenerationRef = useRef(0)
const branchCompareGenerationRef = useRef(0)
const mountedRef = useRef(true)
const openingPathRef = useRef<string | null>(null)
const openingBranchPathRef = useRef<string | null>(null)
const statusLoadInFlightRef = useRef<StatusLoadInFlight | null>(null)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
const statusIdentityKey = `${hostId}\0${worktreeId}`
currentStatusIdentityRef.current = statusIdentityKey
currentBranchCompareIdentityRef.current = statusIdentityKey
useEffect(() => {
return () => {
mountedRef.current = false
loadGenerationRef.current += 1
branchCompareGenerationRef.current += 1
}
}, [])
@ -188,6 +312,63 @@ export default function MobileSourceControlScreen() {
}
}, [insets.bottom])
const loadBranchCompare = useCallback(
async (options?: { preserveReadyOnFailure?: boolean }) => {
const loadKey = statusIdentityKey
const generation = branchCompareGenerationRef.current + 1
branchCompareGenerationRef.current = generation
const isCurrentLoad = () =>
mountedRef.current &&
branchCompareGenerationRef.current === generation &&
currentBranchCompareIdentityRef.current === loadKey
if (!worktreeId || !client || connState !== 'connected') {
if (isCurrentLoad()) {
setBranchCompareState({ kind: 'idle' })
}
return false
}
setBranchCompareState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' }))
try {
const baseRef = await resolveMobileBranchCompareBaseRef(client, worktreeId)
if (!isCurrentLoad()) return false
if (!baseRef) {
setBranchCompareState({ kind: 'idle' })
return true
}
const response = await client.sendRequest('git.branchCompare', {
worktree: `id:${worktreeId}`,
baseRef
})
if (!isCurrentLoad()) return false
if (!response.ok) {
if (isMobileGitUnavailable(response.error?.code, response.error?.message)) {
setBranchCompareState({ kind: 'idle' })
return false
}
throw new Error(response.error?.message || 'Unable to load committed changes')
}
setBranchCompareState({
kind: 'ready',
result: (response as RpcSuccess).result as MobileGitBranchCompareResult
})
return true
} catch (err) {
if (!isCurrentLoad()) return false
const message = err instanceof Error ? err.message : 'Unable to load committed changes'
setBranchCompareState((prev) => {
if (options?.preserveReadyOnFailure && prev.kind === 'ready') {
return prev
}
return { kind: 'error', message }
})
return false
}
},
[client, connState, statusIdentityKey, worktreeId]
)
const loadStatus = useCallback(
async (options?: LoadStatusOptions) => {
const loadKey = statusIdentityKey
@ -230,6 +411,7 @@ export default function MobileSourceControlScreen() {
if (response.ok) {
const result = (response as RpcSuccess).result as MobileGitStatusResult
setScreenState({ kind: 'ready', status: result })
void loadBranchCompare({ preserveReadyOnFailure: true })
if (options?.clearActionErrorOnSuccess !== false) {
setActionError(null)
}
@ -278,7 +460,7 @@ export default function MobileSourceControlScreen() {
}
}
},
[client, connState, statusIdentityKey, worktreeId]
[client, connState, loadBranchCompare, statusIdentityKey, worktreeId]
)
useEffect(() => {
@ -301,6 +483,31 @@ export default function MobileSourceControlScreen() {
[entries]
)
const sections = useMemo(() => buildMobileSourceControlSections(derivedEntries), [derivedEntries])
const branchCompareResult = branchCompareState.kind === 'ready' ? branchCompareState.result : null
const branchCompareSection = useMemo(
() => buildMobileBranchCompareSection(branchCompareResult?.entries ?? []),
[branchCompareResult]
)
const branchCompareSummaryText = branchCompareResult
? formatMobileBranchCompareSummary(branchCompareResult.summary)
: null
const branchCompareCanOpen = branchCompareResult
? canOpenMobileBranchCompareDiff(branchCompareResult.summary)
: false
const branchEntries = useMemo<MobileBranchEntryView[]>(
() =>
(branchCompareSection?.data ?? []).map((entry) => ({
...entry,
canOpen: branchCompareCanOpen
})),
[branchCompareCanOpen, branchCompareSection]
)
const shouldShowBranchCompareSection =
branchEntries.length > 0 ||
branchCompareState.kind === 'loading' ||
branchCompareState.kind === 'error' ||
(branchCompareResult !== null && branchCompareResult.summary.status !== 'ready')
const hasVisibleChanges = sections.length > 0 || shouldShowBranchCompareSection
const stageablePaths = useMemo(() => getStageablePaths(entries), [entries])
const unstageablePaths = useMemo(() => getUnstageablePaths(entries), [entries])
const stagedCount = useMemo(() => countStagedEntries(entries), [entries])
@ -622,13 +829,81 @@ export default function MobileSourceControlScreen() {
[client, connState, hostId, name, origin, router, worktreeId]
)
const openBranchDiff = useCallback(
async (entry: MobileGitBranchChangeEntry) => {
if (openingBranchPathRef.current || openingPathRef.current || busyActionRef.current) return
if (!client || connState !== 'connected') {
if (!mountedRef.current) return
setActionError('Waiting for desktop...')
return
}
if (branchCompareState.kind !== 'ready') {
return
}
const summary = branchCompareState.result.summary
if (!canOpenMobileBranchCompareDiff(summary) || !summary.headOid || !summary.mergeBase) {
return
}
openingBranchPathRef.current = entry.path
setOpeningBranchPath(entry.path)
setBranchDiffPreview({ kind: 'loading', entry })
try {
const response = await client.sendRequest('git.branchDiff', {
worktree: `id:${worktreeId}`,
filePath: entry.path,
...(entry.oldPath ? { oldPath: entry.oldPath } : {}),
compare: {
baseRef: summary.baseRef,
...(summary.baseOid ? { baseOid: summary.baseOid } : {}),
headOid: summary.headOid,
mergeBase: summary.mergeBase
}
})
if (!response.ok) {
throw new Error(response.error?.message || 'Unable to load committed diff')
}
const result = (response as RpcSuccess).result as GitDiffTextResult | { kind: 'binary' }
if (result.kind !== 'text') {
throw new Error('Binary branch diff preview unavailable on mobile')
}
const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent)
if (!mountedRef.current) return
setBranchDiffPreview({
kind: 'ready',
entry,
summary,
lines: diff.lines,
truncated: diff.truncated
})
triggerSelection()
} catch (err) {
if (!mountedRef.current) return
triggerError()
setBranchDiffPreview({
kind: 'error',
entry,
message: err instanceof Error ? err.message : 'Unable to load committed diff'
})
} finally {
if (openingBranchPathRef.current === entry.path) {
openingBranchPathRef.current = null
if (mountedRef.current) {
setOpeningBranchPath(null)
}
}
}
},
[branchCompareState, client, connState, worktreeId]
)
const actionSheetActions = useMemo<ActionSheetAction[]>(() => {
const hasMessage = commitMessage.trim().length > 0
const hasStaged = stagedCount > 0
const hasUpstream = upstream?.hasUpstream === true
const ahead = upstream?.ahead ?? 0
const behind = upstream?.behind ?? 0
const busy = busyAction !== null
const busy = busyAction !== null || openingPath !== null || openingBranchPath !== null
const commitHint = !hasStaged
? 'Stage at least one file'
: !hasMessage
@ -749,6 +1024,8 @@ export default function MobileSourceControlScreen() {
}, [
busyAction,
commitMessage,
openingBranchPath,
openingPath,
runActionSheetCommit,
runActionSheetCommitSequence,
runActionSheetCommitSync,
@ -771,7 +1048,8 @@ export default function MobileSourceControlScreen() {
busyAction === item.unstageActionId ||
busyAction === item.discardActionId ||
openingPath === item.path
const rowDisabled = !item.canOpen || busyAction !== null || openingPath !== null
const rowDisabled =
!item.canOpen || busyAction !== null || openingPath !== null || openingBranchPath !== null
return (
<Pressable
style={({ pressed }) => [
@ -817,10 +1095,11 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.iconButton,
(busyAction !== null || openingPath !== null) && styles.iconButtonDisabled,
(busyAction !== null || openingPath !== null || openingBranchPath !== null) &&
styles.iconButtonDisabled,
pressed && styles.iconButtonPressed
]}
disabled={busyAction !== null || openingPath !== null}
disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null}
onPress={() =>
void runGitAction(item.unstageActionId, 'git.unstage', { filePath: item.path })
}
@ -835,10 +1114,13 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.iconButton,
(busyAction !== null || openingPath !== null) && styles.iconButtonDisabled,
(busyAction !== null || openingPath !== null || openingBranchPath !== null) &&
styles.iconButtonDisabled,
pressed && styles.iconButtonPressed
]}
disabled={busyAction !== null || openingPath !== null}
disabled={
busyAction !== null || openingPath !== null || openingBranchPath !== null
}
onPress={() =>
void runGitAction(item.stageActionId, 'git.stage', { filePath: item.path })
}
@ -852,10 +1134,13 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.iconButton,
(busyAction !== null || openingPath !== null) && styles.iconButtonDisabled,
(busyAction !== null || openingPath !== null || openingBranchPath !== null) &&
styles.iconButtonDisabled,
pressed && styles.iconButtonPressed
]}
disabled={busyAction !== null || openingPath !== null}
disabled={
busyAction !== null || openingPath !== null || openingBranchPath !== null
}
onPress={() => setDiscardTarget(item)}
hitSlop={8}
accessibilityLabel={`Discard ${item.path}`}
@ -868,7 +1153,7 @@ export default function MobileSourceControlScreen() {
</Pressable>
)
},
[busyAction, openFile, openingPath, runGitAction]
[busyAction, openFile, openingBranchPath, openingPath, runGitAction]
)
const keyExtractor = useCallback(
@ -886,6 +1171,170 @@ export default function MobileSourceControlScreen() {
[]
)
const renderBranchCompareFooter = useCallback(() => {
if (!shouldShowBranchCompareSection) {
return null
}
return (
<View style={styles.branchCompareBlock}>
<View style={styles.sectionHeader}>
<View style={styles.branchSectionTitleBlock}>
<Text style={styles.sectionTitle}>Committed on Branch</Text>
{branchCompareSummaryText ? (
<Text style={styles.branchSectionSubtitle} numberOfLines={1}>
{branchCompareSummaryText}
</Text>
) : null}
</View>
<Text style={styles.sectionCount}>{branchEntries.length}</Text>
</View>
{branchCompareState.kind === 'loading' ? (
<View style={styles.branchStateRow}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.branchStateText}>Loading committed changes...</Text>
</View>
) : branchCompareState.kind === 'error' ? (
<View style={styles.branchStateRow}>
<Text style={styles.branchStateText}>{branchCompareState.message}</Text>
</View>
) : branchCompareResult && branchCompareResult.summary.status !== 'ready' ? (
<View style={styles.branchStateRow}>
<Text style={styles.branchStateText}>
{branchCompareResult.summary.errorMessage ?? 'Committed changes unavailable.'}
</Text>
</View>
) : (
branchEntries.map((entry) => {
const rowBusy = openingBranchPath === entry.path
const rowDisabled =
!entry.canOpen ||
busyAction !== null ||
openingPath !== null ||
openingBranchPath !== null
const meta = formatBranchEntryMeta(entry)
return (
<Pressable
key={`${entry.path}:${entry.oldPath ?? ''}`}
style={({ pressed }) => [
styles.fileRow,
pressed && entry.canOpen && styles.fileRowPressed,
rowDisabled && styles.fileRowDisabled,
!entry.canOpen && styles.fileRowUnavailable
]}
onPress={() => void openBranchDiff(entry)}
disabled={rowDisabled}
accessibilityLabel={`Open committed change ${entry.path}`}
>
<View style={styles.statusBadge}>
<Text style={[styles.statusBadgeText, { color: statusColor(entry.status) }]}>
{MOBILE_GIT_STATUS_LABELS[entry.status]}
</Text>
</View>
<FileText
size={16}
color={entry.canOpen ? colors.textSecondary : colors.textMuted}
strokeWidth={2.1}
/>
<View style={styles.fileTextBlock}>
<Text
style={[styles.filePath, !entry.canOpen && styles.filePathDisabled]}
numberOfLines={1}
>
{entry.path}
</Text>
{meta ? (
<Text style={styles.fileMeta} numberOfLines={1}>
{meta}
</Text>
) : null}
</View>
{rowBusy ? <ActivityIndicator size="small" color={colors.textSecondary} /> : null}
</Pressable>
)
})
)}
</View>
)
}, [
branchCompareResult,
branchCompareState,
branchCompareSummaryText,
branchEntries,
busyAction,
openBranchDiff,
openingBranchPath,
openingPath,
shouldShowBranchCompareSection
])
const renderBranchDiffPreview = useCallback(() => {
if (!branchDiffPreview) {
return null
}
const entry = branchDiffPreview.entry
return (
<BottomDrawer
visible={branchDiffPreview !== null}
onClose={() => setBranchDiffPreview(null)}
dragContentToDismiss={false}
zIndex={1100}
>
<View style={styles.diffDrawerHeader}>
<View style={styles.diffDrawerTitleBlock}>
<Text style={styles.diffDrawerTitle} numberOfLines={1}>
{entry.path}
</Text>
<Text style={styles.diffDrawerMeta} numberOfLines={1}>
{branchDiffPreview.kind === 'ready'
? `${branchDiffPreview.summary.baseRef}..HEAD`
: 'Committed on branch'}
</Text>
</View>
<Pressable
style={({ pressed }) => [styles.diffCloseButton, pressed && styles.iconButtonPressed]}
onPress={() => setBranchDiffPreview(null)}
hitSlop={8}
accessibilityLabel="Close committed diff preview"
>
<X size={18} color={colors.textSecondary} strokeWidth={2.1} />
</Pressable>
</View>
{branchDiffPreview.kind === 'loading' ? (
<View style={styles.diffState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : branchDiffPreview.kind === 'error' ? (
<View style={styles.diffState}>
<Text style={styles.stateTitle}>Unable to Load Diff</Text>
<Text style={styles.stateText}>{branchDiffPreview.message}</Text>
</View>
) : (
<View style={styles.diffLines}>
{branchDiffPreview.truncated ? (
<Text style={styles.diffTruncatedText}>Diff truncated for mobile preview.</Text>
) : null}
{branchDiffPreview.lines.map((line, index) => (
<View
key={`${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}`}
style={[
styles.diffLine,
line.kind === 'add' && styles.diffLineAdd,
line.kind === 'delete' && styles.diffLineDelete
]}
>
<Text style={styles.diffLineNumber}>{diffLineNumber(line)}</Text>
<Text style={styles.diffLinePrefix}>{diffLinePrefix(line.kind)}</Text>
<Text style={styles.diffLineText}>{line.text || ' '}</Text>
</View>
))}
</View>
)}
</BottomDrawer>
)
}, [branchDiffPreview])
return (
<View style={styles.container}>
<SafeAreaView style={styles.header} edges={['top']}>
@ -909,11 +1358,12 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.refreshButton,
(busyAction !== null || openingPath !== null) && styles.refreshButtonDisabled,
(busyAction !== null || openingPath !== null || openingBranchPath !== null) &&
styles.refreshButtonDisabled,
pressed && styles.refreshButtonPressed
]}
onPress={() => void loadStatus()}
disabled={busyAction !== null || openingPath !== null}
disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null}
hitSlop={8}
accessibilityLabel="Refresh source control"
>
@ -953,6 +1403,9 @@ export default function MobileSourceControlScreen() {
<View style={styles.countRow}>
<Text style={styles.countText}>{unstagedCount} changed</Text>
<Text style={styles.countText}>{stagedCount} staged</Text>
{branchEntries.length > 0 ? (
<Text style={styles.countText}>{branchEntries.length} on branch</Text>
) : null}
{status && status.conflictOperation !== 'unknown' ? (
<Text style={styles.conflictText}>{status.conflictOperation}</Text>
) : null}
@ -968,13 +1421,19 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.bulkButton,
(stageablePaths.length === 0 || busyAction !== null || openingPath !== null) &&
(stageablePaths.length === 0 ||
busyAction !== null ||
openingPath !== null ||
openingBranchPath !== null) &&
styles.bulkButtonDisabled,
pressed && styles.bulkButtonPressed
]}
onPress={() => void stageAll()}
disabled={
busyAction !== null || openingPath !== null || stageablePaths.length === 0
busyAction !== null ||
openingPath !== null ||
openingBranchPath !== null ||
stageablePaths.length === 0
}
>
{busyAction === 'stage-all' ? (
@ -987,13 +1446,19 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.bulkButton,
(unstageablePaths.length === 0 || busyAction !== null || openingPath !== null) &&
(unstageablePaths.length === 0 ||
busyAction !== null ||
openingPath !== null ||
openingBranchPath !== null) &&
styles.bulkButtonDisabled,
pressed && styles.bulkButtonPressed
]}
onPress={() => void unstageAll()}
disabled={
busyAction !== null || openingPath !== null || unstageablePaths.length === 0
busyAction !== null ||
openingPath !== null ||
openingBranchPath !== null ||
unstageablePaths.length === 0
}
>
{busyAction === 'unstage-all' ? (
@ -1007,10 +1472,11 @@ export default function MobileSourceControlScreen() {
style={({ pressed }) => [
styles.bulkMenuButton,
pressed && styles.bulkButtonPressed,
(busyAction !== null || openingPath !== null) && styles.bulkButtonDisabled
(busyAction !== null || openingPath !== null || openingBranchPath !== null) &&
styles.bulkButtonDisabled
]}
onPress={() => setShowActionSheet(true)}
disabled={busyAction !== null || openingPath !== null}
disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null}
hitSlop={8}
accessibilityLabel="Open source control actions"
>
@ -1019,7 +1485,7 @@ export default function MobileSourceControlScreen() {
</View>
</View>
{entries.length === 0 ? (
{!hasVisibleChanges ? (
<View style={styles.state}>
<Text style={styles.stateTitle}>No Changes</Text>
<Text style={styles.stateText}>Working tree is clean.</Text>
@ -1030,6 +1496,7 @@ export default function MobileSourceControlScreen() {
renderItem={renderItem}
keyExtractor={keyExtractor}
renderSectionHeader={renderSectionHeader}
ListFooterComponent={renderBranchCompareFooter}
stickySectionHeadersEnabled={false}
contentContainerStyle={styles.listContent}
/>
@ -1062,7 +1529,9 @@ export default function MobileSourceControlScreen() {
onChangeText={setCommitMessage}
placeholder="Commit message"
placeholderTextColor={colors.textMuted}
editable={busyAction === null && openingPath === null}
editable={
busyAction === null && openingPath === null && openingBranchPath === null
}
returnKeyType="done"
onSubmitEditing={() => void commit()}
/>
@ -1073,7 +1542,8 @@ export default function MobileSourceControlScreen() {
(!commitMessage.trim() ||
stagedCount === 0 ||
busyAction !== null ||
openingPath !== null) &&
openingPath !== null ||
openingBranchPath !== null) &&
styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
@ -1082,7 +1552,8 @@ export default function MobileSourceControlScreen() {
!commitMessage.trim() ||
stagedCount === 0 ||
busyAction !== null ||
openingPath !== null
openingPath !== null ||
openingBranchPath !== null
}
>
{busyAction === 'commit' ? (
@ -1096,6 +1567,8 @@ export default function MobileSourceControlScreen() {
</>
)}
{renderBranchDiffPreview()}
<ActionSheetModal
visible={showActionSheet}
title="Source Control"
@ -1298,6 +1771,33 @@ const styles = StyleSheet.create({
fontSize: typography.metaSize,
fontWeight: '600'
},
branchCompareBlock: {
paddingBottom: spacing.sm
},
branchSectionTitleBlock: {
flex: 1,
minWidth: 0
},
branchSectionSubtitle: {
color: colors.textMuted,
fontSize: typography.metaSize,
marginTop: 2
},
branchStateRow: {
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
branchStateText: {
flex: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
lineHeight: 18
},
fileRow: {
minHeight: 50,
flexDirection: 'row',
@ -1446,5 +1946,83 @@ const styles = StyleSheet.create({
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
diffDrawerHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingBottom: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
diffDrawerTitleBlock: {
flex: 1,
minWidth: 0
},
diffDrawerTitle: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '700'
},
diffDrawerMeta: {
color: colors.textMuted,
fontSize: typography.metaSize,
marginTop: 2
},
diffCloseButton: {
width: 34,
height: 34,
borderRadius: radii.button,
alignItems: 'center',
justifyContent: 'center'
},
diffState: {
minHeight: 160,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.lg
},
diffLines: {
paddingTop: spacing.md,
paddingBottom: spacing.lg
},
diffTruncatedText: {
color: colors.textMuted,
fontSize: typography.metaSize,
marginBottom: spacing.sm
},
diffLine: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.xs,
paddingVertical: 2,
paddingHorizontal: spacing.xs,
borderRadius: radii.row
},
diffLineAdd: {
backgroundColor: colors.diffAddedBg
},
diffLineDelete: {
backgroundColor: colors.diffDeletedBg
},
diffLineNumber: {
width: 40,
color: colors.textMuted,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize,
textAlign: 'right'
},
diffLinePrefix: {
width: 12,
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize
},
diffLineText: {
flex: 1,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize,
lineHeight: 17
}
})

View File

@ -0,0 +1,65 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import type { GitBranchCompareResult } from '../../../src/shared/types'
import {
buildMobileBranchCompareSection,
canOpenMobileBranchCompareDiff,
formatMobileBranchCompareSummary,
type MobileGitBranchCompareResult
} from './mobile-branch-compare'
describe('mobile branch compare helpers', () => {
it('keeps the mobile branch compare type in lockstep with the runtime contract', () => {
expectTypeOf<MobileGitBranchCompareResult>().toEqualTypeOf<GitBranchCompareResult>()
})
it('sorts committed branch entries by path', () => {
const section = buildMobileBranchCompareSection([
{ path: 'zeta.ts', status: 'modified' },
{ path: 'alpha.ts', status: 'added' }
])
expect(section?.title).toBe('Committed on Branch')
expect(section?.data.map((entry) => entry.path)).toEqual(['alpha.ts', 'zeta.ts'])
})
it('summarizes ready branch compares', () => {
expect(
formatMobileBranchCompareSummary({
baseRef: 'origin/main',
baseOid: 'a'.repeat(40),
compareRef: 'HEAD',
headOid: 'b'.repeat(40),
mergeBase: 'c'.repeat(40),
changedFiles: 2,
commitsAhead: 1,
status: 'ready'
})
).toBe('2 files - 1 commit - vs origin/main')
})
it('only opens branch diffs when compare object ids are available', () => {
expect(
canOpenMobileBranchCompareDiff({
baseRef: 'origin/main',
baseOid: 'a'.repeat(40),
compareRef: 'HEAD',
headOid: 'b'.repeat(40),
mergeBase: 'c'.repeat(40),
changedFiles: 1,
status: 'ready'
})
).toBe(true)
expect(
canOpenMobileBranchCompareDiff({
baseRef: 'origin/main',
baseOid: null,
compareRef: 'HEAD',
headOid: null,
mergeBase: null,
changedFiles: 0,
status: 'unborn-head'
})
).toBe(false)
})
})

View File

@ -0,0 +1,53 @@
import type {
GitBranchChangeEntry,
GitBranchCompareResult,
GitBranchCompareSummary
} from '../../../src/shared/types'
export type MobileGitBranchChangeEntry = GitBranchChangeEntry
export type MobileGitBranchCompareSummary = GitBranchCompareSummary
export type MobileGitBranchCompareResult = GitBranchCompareResult
export type MobileBranchCompareSection<
TEntry extends MobileGitBranchChangeEntry = MobileGitBranchChangeEntry
> = {
title: 'Committed on Branch'
data: TEntry[]
}
function compareBranchEntries(
a: MobileGitBranchChangeEntry,
b: MobileGitBranchChangeEntry
): number {
return a.path.localeCompare(b.path, undefined, { numeric: true })
}
export function buildMobileBranchCompareSection<TEntry extends MobileGitBranchChangeEntry>(
entries: readonly TEntry[]
): MobileBranchCompareSection<TEntry> | null {
if (entries.length === 0) {
return null
}
return {
title: 'Committed on Branch',
data: [...entries].sort(compareBranchEntries)
}
}
export function formatMobileBranchCompareSummary(
summary: MobileGitBranchCompareSummary
): string | null {
if (summary.status !== 'ready') {
return summary.errorMessage ?? null
}
const parts = [`${summary.changedFiles} ${summary.changedFiles === 1 ? 'file' : 'files'}`]
if (summary.commitsAhead !== undefined) {
parts.push(`${summary.commitsAhead} ${summary.commitsAhead === 1 ? 'commit' : 'commits'}`)
}
parts.push(`vs ${summary.baseRef}`)
return parts.join(' - ')
}
export function canOpenMobileBranchCompareDiff(summary: MobileGitBranchCompareSummary): boolean {
return summary.status === 'ready' && Boolean(summary.headOid && summary.mergeBase)
}

View File

@ -150,6 +150,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'git.abortRebase',
'git.bulkStage',
'git.bulkUnstage',
'git.branchCompare',
'git.branchDiff',
'git.commit',
'git.discard',
'git.diff',
@ -237,6 +239,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'preflight.check',
'preflight.detectAgents',
'preflight.detectRemoteAgents',
'repo.baseRefDefault',
'repo.hooks',
'repo.list',
'repo.saveSparsePreset',