mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* feat(mobile): light and dark themes with desktop's palettes Settings → Appearance offers System, Light and Dark. The shell passes the resolved scheme to over-the-air pages in its init frame. Fixes #22184 * fix(mobile): draw your chat messages in desktop's user bubble Desktop uses a faint surface with a hairline border in both themes; the inverted fill went black in light. * test(mobile): re-measure the page script sweep after the shared theme hooks Every route now imports the theme hooks while the entry does not, so they emit one shared script (96 for the tree). Re-swept every route prefix; the worst route step is now 15. * test(mobile): read themed stylesheets in the text-input font-size census The census only unwrapped StyleSheet.create({...}); inputs styled through themedStyleSheet(() => ({...})) came back unread.
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { colors } from '../theme/mobile-theme'
|
|
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
|
import { connectionRetryAction } from '../transport/connection-retry-action'
|
|
import { useMobileSourceControlState } from './use-mobile-source-control-state'
|
|
import { useMobileSourceControlActionSheet } from './use-mobile-source-control-action-sheet'
|
|
import { MobileSourceControlHeader } from './MobileSourceControlHeader'
|
|
import { MobileSourceControlContent } from './MobileSourceControlContent'
|
|
import {
|
|
buildMobileSourceControlPrimaryAction,
|
|
resolveMobileSourceControlReviewInputs
|
|
} from './mobile-source-control-primary-action'
|
|
import { MobileSourceControlModals } from './MobileSourceControlModals'
|
|
import { MobileSourceControlSegments } from './MobileSourceControlSegments'
|
|
import { MobileSourceControlBranchCard } from './MobileSourceControlBranchCard'
|
|
import { MobileGitHistoryList } from './MobileGitHistoryList'
|
|
import { styles } from './mobile-source-control-styles'
|
|
import { hubStyles } from './mobile-source-control-hub-styles'
|
|
import type { SourceControlHubTab } from './mobile-source-control-hub-tab'
|
|
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
|
|
import { isMobileConflictAborting } from './mobile-source-control-conflict-abort'
|
|
import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
|
|
import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state'
|
|
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
|
|
import { openMobilePrUrl } from '../components/mobile-pr-url'
|
|
|
|
export type MobileSourceControlPanelProps = {
|
|
hostId: string
|
|
worktreeId: string
|
|
name?: string
|
|
origin?: string
|
|
embedded?: boolean
|
|
initialTab?: SourceControlHubTab
|
|
onRequestClose?: () => void
|
|
onFileOpenStart?: () => void
|
|
onOpenedFileDiff?: (relativePath: string) => void
|
|
}
|
|
|
|
export function MobileSourceControlPanel({
|
|
hostId,
|
|
worktreeId,
|
|
name = '',
|
|
origin = '',
|
|
embedded = false,
|
|
initialTab = 'changes',
|
|
onRequestClose,
|
|
onFileOpenStart,
|
|
onOpenedFileDiff
|
|
}: MobileSourceControlPanelProps) {
|
|
useMobileColorScheme()
|
|
const [activeTab, setActiveTab] = useState<SourceControlHubTab>(initialTab)
|
|
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<SourceControlHubTab>>(
|
|
() => new Set<SourceControlHubTab>([initialTab])
|
|
)
|
|
const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0)
|
|
|
|
// Query-only navigation reuses this screen; adopt the new tab.
|
|
useEffect(() => {
|
|
setActiveTab(initialTab)
|
|
setVisitedTabs((prev) => {
|
|
if (prev.has(initialTab)) {
|
|
return prev
|
|
}
|
|
const next = new Set(prev)
|
|
next.add(initialTab)
|
|
return next
|
|
})
|
|
}, [initialTab])
|
|
|
|
const selectTab = useCallback((tab: SourceControlHubTab) => {
|
|
setActiveTab(tab)
|
|
setVisitedTabs((prev) => {
|
|
if (prev.has(tab)) {
|
|
return prev
|
|
}
|
|
const next = new Set(prev)
|
|
next.add(tab)
|
|
return next
|
|
})
|
|
}, [])
|
|
const openHistoryTab = useCallback(() => selectTab('history'), [selectTab])
|
|
const openPrTab = useCallback(() => selectTab('pr'), [selectTab])
|
|
|
|
const state = useMobileSourceControlState({
|
|
hostId,
|
|
worktreeId,
|
|
name,
|
|
origin,
|
|
embedded,
|
|
onRequestClose,
|
|
onFileOpenStart,
|
|
onOpenedFileDiff,
|
|
onOpenHistory: openHistoryTab
|
|
})
|
|
const {
|
|
client,
|
|
connState,
|
|
forceReconnect,
|
|
insets,
|
|
router,
|
|
setRootRef,
|
|
worktreeLabel,
|
|
screenState,
|
|
busyAction,
|
|
openingPath,
|
|
openingBranchPath,
|
|
loadStatus,
|
|
status,
|
|
branchCompareResult,
|
|
branchLabel,
|
|
syncLabel,
|
|
unstagedCount,
|
|
stagedCount,
|
|
branchEntries,
|
|
abortConflictOperation
|
|
} = state
|
|
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
|
|
const ready = screenState.kind === 'ready'
|
|
|
|
// Retain branch/head through status unloads so the PR controller does not reset to a cold start.
|
|
const lastPrBranchRef = useRef<string | null>(null)
|
|
const lastPrHeadRef = useRef<string | null>(null)
|
|
useEffect(() => {
|
|
lastPrBranchRef.current = null
|
|
lastPrHeadRef.current = null
|
|
}, [worktreeId])
|
|
const statusBranch = status?.branch ?? null
|
|
const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null
|
|
// Update fallback identity after commit; discarded concurrent renders must not change it.
|
|
useEffect(() => {
|
|
if (statusBranch) {
|
|
lastPrBranchRef.current = statusBranch
|
|
}
|
|
if (statusHead) {
|
|
lastPrHeadRef.current = statusHead
|
|
}
|
|
}, [statusBranch, statusHead])
|
|
const prBranch = statusBranch ?? lastPrBranchRef.current
|
|
const prHeadSha = statusHead ?? lastPrHeadRef.current
|
|
const prController = useMobilePrSidebarController({
|
|
client,
|
|
connState,
|
|
worktreeId,
|
|
branch: prBranch,
|
|
headSha: prHeadSha
|
|
})
|
|
const prSidebarState = prController.prSidebarState
|
|
const review = useMemo(
|
|
() => resolveMobileSourceControlReviewInputs(prSidebarState),
|
|
[prSidebarState]
|
|
)
|
|
const primaryActionArgs = state.primaryActionArgs
|
|
const actionSheetActions = useMobileSourceControlActionSheet(state, review)
|
|
const primaryAction = useMemo(
|
|
() => buildMobileSourceControlPrimaryAction({ ...primaryActionArgs, review }),
|
|
[primaryActionArgs, review]
|
|
)
|
|
const isHostedRepo = prController.prSidebarIsGithubRepo
|
|
const prSidebarKind = prController.prSidebarState.kind
|
|
const refetchPr = prController.refetchPRSidebar
|
|
const ensurePrDetails = prController.ensurePrSidebarDetails
|
|
// Keep tab effects stable when soft-refresh load changes with headSha.
|
|
const refetchPrRef = useRef(refetchPr)
|
|
refetchPrRef.current = refetchPr
|
|
const ensurePrDetailsRef = useRef(ensurePrDetails)
|
|
ensurePrDetailsRef.current = ensurePrDetails
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'pr') {
|
|
return
|
|
}
|
|
if (prBranch && isHostedRepo && prSidebarKind === 'hidden') {
|
|
void refetchPrRef.current({ includeDetails: false })
|
|
}
|
|
}, [activeTab, prBranch, isHostedRepo, prSidebarKind])
|
|
|
|
const prDetailsMissingFor =
|
|
prController.prSidebarState.kind === 'ready' &&
|
|
prSidebarDetailsNeedFetch(prController.prSidebarState.data.details)
|
|
? prController.prSidebarState.data.pr.number
|
|
: null
|
|
|
|
useEffect(() => {
|
|
if (activeTab !== 'pr' || !isHostedRepo || !prBranch) {
|
|
return
|
|
}
|
|
if (prSidebarKind === 'hidden') {
|
|
void refetchPrRef.current({ includeDetails: true })
|
|
return
|
|
}
|
|
if (prDetailsMissingFor != null) {
|
|
void ensurePrDetailsRef.current()
|
|
}
|
|
}, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor])
|
|
|
|
const prChip = useMemo(() => {
|
|
// Detached HEAD never loads PR state; hide the chip instead of leaving it spinning.
|
|
if (!isHostedRepo || !prBranch) {
|
|
return null
|
|
}
|
|
const commentCount =
|
|
prController.prSidebarState.kind === 'ready'
|
|
? countUnresolvedReviewThreads(prController.prSidebarState.data.details?.comments)
|
|
: null
|
|
return buildMobilePrChipSummary(prController.prSidebarState, commentCount)
|
|
}, [isHostedRepo, prBranch, prController.prSidebarState])
|
|
|
|
const onRefresh = useCallback(() => {
|
|
void loadStatus({ preserveReadyOnFailure: true, rereadSettings: true })
|
|
if (activeTab === 'history') {
|
|
setHistoryRefreshNonce((n) => n + 1)
|
|
return
|
|
}
|
|
if (!isHostedRepo) {
|
|
return
|
|
}
|
|
if (activeTab === 'pr') {
|
|
void refetchPr({ includeDetails: true })
|
|
return
|
|
}
|
|
void refetchPr({ includeDetails: false })
|
|
}, [activeTab, isHostedRepo, loadStatus, refetchPr])
|
|
|
|
// Embedded close dismisses the dock; route Back pops. The dock column owns safe-area chrome.
|
|
const onBack = () => router.back()
|
|
const onClose = onRequestClose ?? (() => router.back())
|
|
const prWebUrl =
|
|
activeTab === 'pr' &&
|
|
prController.prSidebarState.kind === 'ready' &&
|
|
prController.prSidebarState.data.pr.url
|
|
? prController.prSidebarState.data.pr.url
|
|
: null
|
|
const prWebNumber =
|
|
prController.prSidebarState.kind === 'ready' ? prController.prSidebarState.data.pr.number : null
|
|
const header = (
|
|
<MobileSourceControlHeader
|
|
embedded={embedded}
|
|
worktreeLabel={worktreeLabel}
|
|
ioBusy={ioBusy}
|
|
onBack={onBack}
|
|
onClose={onClose}
|
|
onRefresh={onRefresh}
|
|
onOpenPrWeb={prWebUrl ? () => openMobilePrUrl(prWebUrl) : undefined}
|
|
prNumber={prWebNumber}
|
|
/>
|
|
)
|
|
|
|
// Revive parked reconnect loops; loadStatus reruns after connection.
|
|
const statusRetry = connectionRetryAction({
|
|
hostId,
|
|
needsReconnect: connState !== 'connected',
|
|
forceReconnect,
|
|
reload: () => void loadStatus()
|
|
})
|
|
const statusGate =
|
|
screenState.kind === 'loading' ? (
|
|
<View style={styles.state}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? (
|
|
<View style={styles.state}>
|
|
<Text style={styles.stateTitle}>
|
|
{screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'}
|
|
</Text>
|
|
<Text style={styles.stateText}>{screenState.message}</Text>
|
|
{screenState.kind === 'error' && statusRetry ? (
|
|
<Pressable style={styles.retryButton} onPress={statusRetry}>
|
|
<Text style={styles.retryText}>Retry</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
) : null
|
|
|
|
// History needs only RPC; Changes and PR also need branch/file status.
|
|
const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes'))
|
|
const showHistory = activeTab === 'history' || visitedTabs.has('history')
|
|
// Unmount inactive PR content to release Mermaid WebViews and avoid comment rerenders per keystroke.
|
|
const showPrBody = ready && activeTab === 'pr'
|
|
const conflictOperation = status?.conflictOperation ?? null
|
|
const hasActiveConflict = conflictOperation != null && conflictOperation !== 'unknown'
|
|
const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation)
|
|
|
|
return (
|
|
<View ref={setRootRef} style={styles.container}>
|
|
{embedded ? (
|
|
<View style={styles.header}>{header}</View>
|
|
) : (
|
|
<SafeAreaView style={styles.header} edges={['top']}>
|
|
{header}
|
|
</SafeAreaView>
|
|
)}
|
|
|
|
<MobileSourceControlSegments active={activeTab} onSelect={selectTab} />
|
|
|
|
{/* Ready PRs duplicate the branch card; retain it only when needed to abort a conflict. */}
|
|
{ready && (activeTab !== 'pr' || hasActiveConflict) ? (
|
|
<MobileSourceControlBranchCard
|
|
branchLabel={branchLabel}
|
|
syncLabel={syncLabel}
|
|
unstagedCount={unstagedCount}
|
|
stagedCount={stagedCount}
|
|
branchCount={branchEntries.length}
|
|
conflictOperation={conflictOperation}
|
|
conflictBusy={busyAction !== null}
|
|
conflictAborting={conflictAborting}
|
|
onAbortConflict={(operation) => void abortConflictOperation(operation)}
|
|
prChip={prChip}
|
|
onOpenPr={openPrTab}
|
|
/>
|
|
) : null}
|
|
|
|
{showChanges ? (
|
|
<View style={activeTab === 'changes' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
|
|
<MobileSourceControlContent state={state} primaryAction={primaryAction} />
|
|
</View>
|
|
) : activeTab === 'changes' ? (
|
|
statusGate
|
|
) : null}
|
|
|
|
{showPrBody ? (
|
|
<View style={hubStyles.tabBody}>
|
|
<MobilePrViewPanelBody
|
|
client={client}
|
|
connState={connState}
|
|
worktreeId={worktreeId}
|
|
branch={prBranch}
|
|
headSha={prHeadSha}
|
|
gitStatus={status}
|
|
isGithubRepo={isHostedRepo}
|
|
// Mid-probe false is still loading; do not flash unavailable.
|
|
branchContextLoaded={ready && prController.prSidebarRepoProbeLoaded}
|
|
controller={prController}
|
|
workspaceLabel={worktreeLabel}
|
|
/>
|
|
</View>
|
|
) : activeTab === 'pr' ? (
|
|
statusGate
|
|
) : null}
|
|
|
|
{showHistory ? (
|
|
<View style={activeTab === 'history' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
|
|
<MobileGitHistoryList
|
|
client={client}
|
|
connState={connState}
|
|
worktreeId={worktreeId}
|
|
hostId={hostId}
|
|
bottomInset={insets.bottom}
|
|
refreshNonce={historyRefreshNonce}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
|
|
<MobileSourceControlModals state={state} actionSheetActions={actionSheetActions} />
|
|
</View>
|
|
)
|
|
}
|