Files
orca/mobile/src/source-control/MobileSourceControlContent.tsx
T
NeilandOrca 17cfc968cf Revert the terminal IME composition-ownership change (#13282)
* Revert "test(ime): restore coverage the composition-ownership change removed (#13168)"

This reverts commit 25a8c517e1.

* Revert "refactor(terminal): return IME composition ownership to xterm (#13128)"

This reverts commit 17b3dff3c4.

* test(ime): keep the architecture-neutral Korean trace coverage

The recorded IBus/fcitx5 and Windows MS-Korean traces from #13168 assert PTY
byte order, not composition ownership, so they still hold once the terminal
composition layer is restored. The mobile accessory-order test pinned the new
handleLiveInputChange signature and does not.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): keep the macOS Backslash bypass through the revert

The restored native-text forwarder only claims keys for input sources in its
hardcoded CJK allowlist, so third-party IMEs off that list (Qingg, #10896) still
get a raw backslash. #13128 added this bypass as a partial replacement; keep it
rather than trade the open issue back.

Scoped to the bare backslash key. The rest of shouldBypassXtermForMacNativeText
bypassed all unmodified non-ASCII text, which would race the restored forwarder.

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): move the mirror-step ref write out of render

The restored hook assigned runMirrorStepRef during render, which is not
replay-safe — React can discard render work, so the mutation can leak from UI
that never commits. Its only read is inside the held-commit timer, which fires
long after commit, and the ref has a safe default, so an effect is soon enough.

Surfaced by the changed-lines React Doctor gate: the rule postdates this code,
so restoring the file re-introduced it as a new violation.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-08 19:06:50 -07:00

277 lines
9.9 KiB
TypeScript

import {
ActivityIndicator,
Pressable,
ScrollView,
SectionList,
Text,
TextInput,
View
} from 'react-native'
import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry'
import { MobileCommitFailurePanel } from './MobileCommitFailurePanel'
import { KEYBOARD_COMMIT_BAR_CLEARANCE } from './mobile-source-control-screen-state'
import { makeRenderFileRow, BranchCompareFooter } from './MobileSourceControlFileRows'
import type { MobileSourceControlState } from './use-mobile-source-control-state'
import { styles } from './mobile-source-control-styles'
import { hubStyles } from './mobile-source-control-hub-styles'
type Props = {
state: MobileSourceControlState
}
// Changes tab: local file changes only — uncommitted (staged/unstaged) plus
// committed-on-branch vs base. PR conflicts and push status live elsewhere.
export function MobileSourceControlContent({ state }: Props) {
const {
insets,
connState,
busyAction,
commitMessage,
setCommitMessage,
generatingMessage,
setShowActionSheet,
setDiscardTarget,
actionError,
commitFailureRecovery,
commitFailureRecoveryAction,
keyboardLift,
openingPath,
openingBranchPath,
sections,
hasVisibleChanges,
stageablePaths,
unstageablePaths,
stagedCount,
primaryAction,
createPrAction,
stageAll,
unstageAll,
generateCommitMessage,
cancelGenerateCommitMessage,
openFile,
openBranchDiff,
runGitAction
} = state
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
const shouldShowGenerateButton = stagedCount > 0 || generatingMessage
const createPrHeroActive =
createPrAction.visible && !createPrAction.disabled && !createPrAction.pushFirst
const branchCompareFooter = (
<BranchCompareFooter
state={{
shouldShowBranchCompareSection: state.shouldShowBranchCompareSection,
branchCompareSummaryText: state.branchCompareSummaryText,
branchEntries: state.branchEntries,
branchCompareState: state.branchCompareState,
branchCompareResult: state.branchCompareResult,
busyAction,
openBranchDiff,
openingBranchPath,
openingPath
}}
/>
)
return (
<>
{connState !== 'connected' ? (
// Why: once data has loaded the screen looks alive even when the
// desktop link is down, so taps appear to do nothing (STA-1511).
// Surface the reconnect state where the user is looking.
<View style={styles.reconnectBanner}>
<ActivityIndicator size="small" color={colors.statusAmber} />
<Text style={styles.reconnectBannerText}>Reconnecting to desktop...</Text>
</View>
) : null}
<View style={hubStyles.changesControls}>
{commitFailureRecovery ? (
<MobileCommitFailurePanel
failure={commitFailureRecovery}
action={commitFailureRecoveryAction}
/>
) : actionError ? (
<View style={styles.actionError}>
<Text style={styles.actionErrorText} numberOfLines={2}>
{actionError}
</Text>
</View>
) : null}
<MobileSourceControlCreatePrEntry action={createPrAction} />
<View style={styles.bulkRow}>
<Pressable
style={({ pressed }) => [
styles.bulkButton,
(stageablePaths.length === 0 || ioBusy) && styles.bulkButtonDisabled,
pressed && styles.bulkButtonPressed
]}
onPress={() => void stageAll()}
disabled={ioBusy || stageablePaths.length === 0}
>
{busyAction === 'stage-all' ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Plus size={15} color={colors.textPrimary} strokeWidth={2.2} />
)}
<Text style={styles.bulkButtonText}>Stage All</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.bulkButton,
(unstageablePaths.length === 0 || ioBusy) && styles.bulkButtonDisabled,
pressed && styles.bulkButtonPressed
]}
onPress={() => void unstageAll()}
disabled={ioBusy || unstageablePaths.length === 0}
>
{busyAction === 'unstage-all' ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Minus size={15} color={colors.textPrimary} strokeWidth={2.2} />
)}
<Text style={styles.bulkButtonText}>Unstage All</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.bulkMenuButton,
pressed && styles.bulkButtonPressed,
ioBusy && styles.bulkButtonDisabled
]}
onPress={() => setShowActionSheet(true)}
disabled={ioBusy}
hitSlop={8}
accessibilityLabel="Open source control actions"
>
<MoreHorizontal size={18} color={colors.textPrimary} strokeWidth={2.1} />
</Pressable>
</View>
</View>
{!hasVisibleChanges ? (
<View style={styles.state}>
<Text style={styles.stateTitle}>No local changes</Text>
<Text style={styles.stateText}>Working tree is clean.</Text>
</View>
) : sections.length === 0 ? (
// Why: RN SectionList with empty `sections` often skips ListFooterComponent,
// which hid "Committed on Branch" when only branch files remain.
<ScrollView style={hubStyles.tabBody} contentContainerStyle={styles.listContent}>
{branchCompareFooter}
</ScrollView>
) : (
<SectionList
style={hubStyles.tabBody}
sections={sections}
renderItem={makeRenderFileRow({
busyAction,
openingPath,
openingBranchPath,
openFile,
runGitAction,
setDiscardTarget
})}
keyExtractor={(item) => `${item.area}:${item.path}:${item.oldPath ?? ''}`}
renderSectionHeader={({ section }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<Text style={styles.sectionCount}>{section.data.length}</Text>
</View>
)}
ListFooterComponent={branchCompareFooter}
stickySectionHeadersEnabled={false}
contentContainerStyle={styles.listContent}
/>
)}
<View
style={[
styles.commitBar,
{
bottom: keyboardLift > 0 ? keyboardLift + KEYBOARD_COMMIT_BAR_CLEARANCE : keyboardLift,
paddingBottom: keyboardLift > 0 ? spacing.md : spacing.md + insets.bottom
}
]}
>
<View style={styles.commitRow}>
{stagedCount === 0 ? (
<View
style={[styles.commitInput, styles.commitInputDisabled]}
accessibilityRole="text"
accessibilityState={{ disabled: true }}
accessibilityLabel="Commit message disabled. No staged files."
>
<Text style={styles.commitInputDisabledText}>No staged files</Text>
</View>
) : (
<TextInput
style={styles.commitInput}
value={commitMessage}
onChangeText={setCommitMessage}
placeholder="Commit message"
placeholderTextColor={colors.textMuted}
editable={busyAction === null && openingPath === null && openingBranchPath === null}
returnKeyType="done"
onSubmitEditing={primaryAction.onPress}
/>
)}
{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,
createPrHeroActive && styles.commitButtonSecondary,
primaryAction.disabled && styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
onPress={primaryAction.onPress}
disabled={primaryAction.disabled}
accessibilityLabel={primaryAction.accessibilityLabel}
accessibilityHint={primaryAction.accessibilityHint}
>
{primaryAction.loading ? (
<ActivityIndicator
size="small"
color={createPrHeroActive ? colors.textPrimary : colors.bgBase}
/>
) : (
<Text
style={[
styles.commitButtonText,
createPrHeroActive && styles.commitButtonSecondaryText
]}
>
{primaryAction.label}
</Text>
)}
</Pressable>
</View>
</View>
</>
)
}