Files
orca/mobile/src/source-control/MobileSourceControlFileRows.tsx
T
Neilandzhangxaochen 720f45c8f7 Fix mobile Source Control rendering for large branches
Render committed branch changes as virtualized SectionList rows for clean and dirty working trees, keeping all files accessible without delaying Back. Share the existing sleek scrollbar rules with mobile web pages and retain explicit hidden-indicator settings.

Fixes #30170. Review reference: #30171; thanks to @zhangxaochen for the diagnosis and initial proposed fix.

Co-authored-by: zhangxaochen <zhangxaochen@163.com>
2026-10-18 00:20:23 -07:00

250 lines
8.4 KiB
TypeScript

import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { ChevronRight, FileText, Minus, Plus, Trash2 } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { MOBILE_GIT_STATUS_LABELS } from './mobile-git-status'
import { formatMobileBranchEntryMeta } from './mobile-branch-entry-format'
import {
statusColor,
type MobileBranchEntryView,
type MobileGitStatusEntryView
} from './mobile-source-control-screen-state'
import type { MobileSourceControlState } from './use-mobile-source-control-state'
import { styles } from './mobile-source-control-styles'
type RowState = Pick<
MobileSourceControlState,
| 'busyAction'
| 'openingPath'
| 'openingBranchPath'
| 'openFile'
| 'runGitAction'
| 'setDiscardTarget'
>
export function makeRenderFileRow(state: RowState) {
const { busyAction, openingPath, openingBranchPath, openFile, runGitAction, setDiscardTarget } =
state
return function FileRow({ item }: { item: MobileGitStatusEntryView }) {
const rowBusy =
busyAction === item.stageActionId ||
busyAction === item.unstageActionId ||
busyAction === item.discardActionId ||
openingPath === item.path
const rowDisabled =
!item.canOpen || busyAction !== null || openingPath !== null || openingBranchPath !== null
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
return (
<Pressable
style={({ pressed }) => [
styles.fileRow,
pressed && item.canOpen && styles.fileRowPressed,
rowDisabled && styles.fileRowDisabled,
!item.canOpen && styles.fileRowUnavailable
]}
onPress={() => void openFile(item)}
disabled={rowDisabled}
accessibilityLabel={`Open changed file ${item.path}`}
>
<View style={styles.statusBadge}>
<Text style={[styles.statusBadgeText, { color: statusColor(item.status) }]}>
{MOBILE_GIT_STATUS_LABELS[item.status]}
</Text>
</View>
<FileText
size={16}
color={item.canOpen ? colors.textSecondary : colors.textMuted}
strokeWidth={2.1}
/>
<View style={styles.fileTextBlock}>
<Text
style={[styles.filePath, !item.canOpen && styles.filePathDisabled]}
numberOfLines={1}
>
{item.path}
</Text>
{item.oldPath ? (
<Text style={styles.fileMeta} numberOfLines={1}>
from {item.oldPath}
</Text>
) : item.conflictStatus === 'unresolved' ? (
<Text style={styles.fileMeta} numberOfLines={1}>
Unresolved conflict
</Text>
) : null}
</View>
{rowBusy ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : item.area === 'staged' ? (
<Pressable
style={({ pressed }) => [
styles.iconButton,
ioBusy && styles.iconButtonDisabled,
pressed && styles.iconButtonPressed
]}
disabled={ioBusy}
onPress={() =>
void runGitAction(item.unstageActionId, 'git.unstage', { filePath: item.path })
}
hitSlop={8}
accessibilityLabel={`Unstage ${item.path}`}
>
<Minus size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : item.canStage || item.canDiscard ? (
<View style={styles.rowActions}>
{item.canStage ? (
<Pressable
style={({ pressed }) => [
styles.iconButton,
ioBusy && styles.iconButtonDisabled,
pressed && styles.iconButtonPressed
]}
disabled={ioBusy}
onPress={() =>
void runGitAction(item.stageActionId, 'git.stage', { filePath: item.path })
}
hitSlop={8}
accessibilityLabel={`Stage ${item.path}`}
>
<Plus size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : null}
{item.canDiscard ? (
<Pressable
style={({ pressed }) => [
styles.iconButton,
ioBusy && styles.iconButtonDisabled,
pressed && styles.iconButtonPressed
]}
disabled={ioBusy}
onPress={() => setDiscardTarget(item)}
hitSlop={8}
accessibilityLabel={`Discard ${item.path}`}
>
<Trash2 size={16} color={colors.statusRed} strokeWidth={2.1} />
</Pressable>
) : null}
</View>
) : null}
{!rowBusy && item.canOpen ? (
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
) : null}
</Pressable>
)
}
}
type BranchState = Pick<
MobileSourceControlState,
| 'shouldShowBranchCompareSection'
| 'branchCompareSummaryText'
| 'branchEntries'
| 'branchCompareState'
| 'branchCompareResult'
| 'busyAction'
| 'openBranchDiff'
| 'openingBranchPath'
| 'openingPath'
>
export function BranchCompareHeader({ state }: { state: BranchState }) {
const {
shouldShowBranchCompareSection,
branchCompareSummaryText,
branchEntries,
branchCompareState,
branchCompareResult
} = state
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>
) : null}
</View>
)
}
export function BranchFileRow({
entry,
state
}: {
entry: MobileBranchEntryView
state: BranchState
}) {
const { busyAction, openBranchDiff, openingBranchPath, openingPath } = state
const rowBusy = openingBranchPath === entry.path
const rowDisabled =
!entry.canOpen || busyAction !== null || openingPath !== null || openingBranchPath !== null
const meta = formatMobileBranchEntryMeta(entry)
return (
<Pressable
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} />
) : entry.canOpen ? (
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
) : null}
</Pressable>
)
}