mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Consolidate mobile source control into a single tabbed hub
Unify the changes list, pull request details, and commit history into a single multi-segment panel. This improves navigation and state sharing across different lenses of a worktree's source control. - Add a segmented control to switch between Changes, PR, and History - Introduce a persistent branch status card with an integrated PR chip - Redirect standalone PR and history routes to the new unified hub - Extract reusable UI and logic for the history list and PR summary
This commit is contained in:
@@ -1,242 +1,26 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react-native'
|
||||
import { useForceReconnect, useHostClient } from '../../../../src/transport/client-context'
|
||||
import type { RpcSuccess } from '../../../../src/transport/types'
|
||||
import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
|
||||
import {
|
||||
fetchMobileGitHistory,
|
||||
mapMobileCommitRows,
|
||||
type MobileCommitRow
|
||||
} from '../../../../src/source-control/mobile-git-history'
|
||||
import { resolveMobileHistoryScreenView } from '../../../../src/source-control/mobile-history-screen-state'
|
||||
import type { GitBranchChangeEntry } from '../../../../../src/shared/types'
|
||||
import { Redirect, useLocalSearchParams } from 'expo-router'
|
||||
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
|
||||
|
||||
function firstParam(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
|
||||
}
|
||||
|
||||
export default function HistoryScreen() {
|
||||
// History is now a segment of the Source Control hub. This route stays as a thin
|
||||
// redirect so existing deep links (and any cached navigation) land on the hub with
|
||||
// the History segment selected.
|
||||
export default function HistoryRedirect() {
|
||||
const params = useLocalSearchParams<{
|
||||
hostId?: string | string[]
|
||||
worktreeId?: string | string[]
|
||||
name?: string | string[]
|
||||
}>()
|
||||
const hostId = firstParam(params.hostId)
|
||||
const worktreeId = firstParam(params.worktreeId)
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const forceReconnect = useForceReconnect()
|
||||
|
||||
const [rows, setRows] = useState<MobileCommitRow[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!client || connState !== 'connected' || !worktreeId) {
|
||||
return
|
||||
}
|
||||
// Reset prior error/rows so a successful retry doesn't stay stuck behind a
|
||||
// stale error (error wins render precedence).
|
||||
setError(null)
|
||||
setRows(null)
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await fetchMobileGitHistory(client, worktreeId)
|
||||
if (active) {
|
||||
setRows(mapMobileCommitRows(result, Date.now()))
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load history')
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [client, connState, reloadNonce, worktreeId])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setError(null)
|
||||
// Why: retrying the fetch is useless while the transport's reconnect loop
|
||||
// is parked at its backoff cap — revive the connection instead (mirrors
|
||||
// MobileSourceControlPanel / issue #5049). The load effect re-runs via
|
||||
// connState once the fresh client connects.
|
||||
if (connState !== 'connected' && hostId) {
|
||||
void forceReconnect(hostId)
|
||||
return
|
||||
}
|
||||
setReloadNonce((n) => n + 1)
|
||||
}, [connState, forceReconnect, hostId])
|
||||
|
||||
const toggleCommit = useCallback(
|
||||
(row: MobileCommitRow) => {
|
||||
const next = expanded === row.id ? null : row.id
|
||||
setExpanded(next)
|
||||
if (next && client && !filesById[row.id]) {
|
||||
setFilesById((prev) => ({ ...prev, [row.id]: 'loading' }))
|
||||
void client
|
||||
.sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id })
|
||||
.then((response) => {
|
||||
const entries = response.ok
|
||||
? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries
|
||||
: []
|
||||
setFilesById((prev) => ({ ...prev, [row.id]: entries }))
|
||||
})
|
||||
.catch(() => setFilesById((prev) => ({ ...prev, [row.id]: [] })))
|
||||
}
|
||||
},
|
||||
[client, expanded, filesById, worktreeId]
|
||||
)
|
||||
|
||||
const renderCommit = useCallback(
|
||||
({ item }: { item: MobileCommitRow }) => {
|
||||
const files = filesById[item.id]
|
||||
const isOpen = expanded === item.id
|
||||
return (
|
||||
<View style={styles.commit}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.commitHeader, pressed && styles.commitHeaderPressed]}
|
||||
onPress={() => toggleCommit(item)}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={colors.textMuted} />
|
||||
)}
|
||||
<View style={styles.commitMain}>
|
||||
<Text style={styles.commitSubject} numberOfLines={1}>
|
||||
{item.subject}
|
||||
</Text>
|
||||
<Text style={styles.commitMeta} numberOfLines={1}>
|
||||
{item.shortId} · {item.author} · {item.relativeTime}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{isOpen ? (
|
||||
<View style={styles.files}>
|
||||
{files === 'loading' || files === undefined ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : files.length === 0 ? (
|
||||
<Text style={styles.empty}>No file changes</Text>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<View key={file.path} style={styles.fileRow}>
|
||||
<Text style={styles.filePath} numberOfLines={1}>
|
||||
{file.path}
|
||||
</Text>
|
||||
<Text style={styles.fileStat}>
|
||||
{file.added ? <Text style={styles.add}>+{file.added} </Text> : null}
|
||||
{file.removed ? <Text style={styles.del}>-{file.removed}</Text> : null}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
[expanded, filesById, toggleCommit]
|
||||
)
|
||||
|
||||
const view = resolveMobileHistoryScreenView({
|
||||
connected: client !== null && connState === 'connected',
|
||||
rows,
|
||||
error
|
||||
})
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable style={styles.back} onPress={() => router.back()} accessibilityLabel="Back">
|
||||
<ChevronLeft size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>Commit History</Text>
|
||||
</View>
|
||||
{view.kind === 'error' || view.kind === 'waiting' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>
|
||||
{view.kind === 'waiting' ? 'Waiting for desktop...' : view.message}
|
||||
</Text>
|
||||
<Pressable style={styles.retryButton} onPress={retry} accessibilityLabel="Retry">
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : view.kind === 'loading' ? (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
</View>
|
||||
) : view.kind === 'empty' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>No commits.</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={view.rows}
|
||||
renderItem={renderCommit}
|
||||
keyExtractor={(row) => row.id}
|
||||
contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
<Redirect
|
||||
href={{
|
||||
pathname: '/h/[hostId]/source-control/[worktreeId]',
|
||||
params: {
|
||||
hostId: firstParam(params.hostId),
|
||||
worktreeId: firstParam(params.worktreeId),
|
||||
name: firstParam(params.name),
|
||||
tab: 'history'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bgBase },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.sm
|
||||
},
|
||||
back: { padding: spacing.xs },
|
||||
title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
|
||||
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
|
||||
stateText: { color: colors.textMuted, fontSize: typography.bodySize },
|
||||
retryButton: {
|
||||
marginTop: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
|
||||
commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle },
|
||||
commitHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2
|
||||
},
|
||||
commitHeaderPressed: { backgroundColor: colors.bgRaised },
|
||||
commitMain: { flex: 1, minWidth: 0 },
|
||||
commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize },
|
||||
commitMeta: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily,
|
||||
marginTop: 2
|
||||
},
|
||||
files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 },
|
||||
fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
filePath: {
|
||||
flex: 1,
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily },
|
||||
add: { color: colors.gitDecorationAdded },
|
||||
del: { color: colors.gitDecorationDeleted },
|
||||
empty: { color: colors.textMuted, fontSize: typography.metaSize }
|
||||
})
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { useHostClient } from '../../../../src/transport/client-context'
|
||||
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
|
||||
import { MobilePrViewPanel } from '../../../../src/components/pr-sidebar/MobilePrViewPanel'
|
||||
|
||||
// Narrow-layout full-screen PR route. The standalone panel can't ride on the review
|
||||
// screen's diff state, so branch/head SHA are resolved here from git.status + branchCompare.
|
||||
export default function MobilePrViewScreen() {
|
||||
const { hostId, worktreeId } = useLocalSearchParams<{ hostId: string; worktreeId: string }>()
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const { branch, headSha, status, isGithubRepo, repoLoaded, loaded } = useMobilePrBranchContext({
|
||||
client,
|
||||
connState,
|
||||
worktreeId
|
||||
})
|
||||
import { Redirect, useLocalSearchParams } from 'expo-router'
|
||||
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
|
||||
|
||||
// The Pull Request view is now a segment of the Source Control hub. This route
|
||||
// stays as a thin redirect so existing deep links land on the hub with the Pull
|
||||
// Request segment selected. The wide-layout dock still renders MobilePrViewPanel
|
||||
// directly (it does not route through here).
|
||||
export default function PrRedirect() {
|
||||
const params = useLocalSearchParams<{
|
||||
hostId?: string | string[]
|
||||
worktreeId?: string | string[]
|
||||
name?: string | string[]
|
||||
}>()
|
||||
return (
|
||||
<MobilePrViewPanel
|
||||
client={client}
|
||||
connState={connState}
|
||||
worktreeId={worktreeId}
|
||||
branch={branch}
|
||||
headSha={headSha}
|
||||
gitStatus={status}
|
||||
isGithubRepo={isGithubRepo}
|
||||
branchContextLoaded={loaded && repoLoaded}
|
||||
embedded={false}
|
||||
<Redirect
|
||||
href={{
|
||||
pathname: '/h/[hostId]/source-control/[worktreeId]',
|
||||
params: {
|
||||
hostId: firstParam(params.hostId),
|
||||
worktreeId: firstParam(params.worktreeId),
|
||||
name: firstParam(params.name),
|
||||
tab: 'pr'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4431,14 +4431,17 @@ export default function SessionScreen() {
|
||||
setActivePanel(action.next)
|
||||
return
|
||||
}
|
||||
const descriptor = panelRouteDescriptor(action.panel)
|
||||
router.push({
|
||||
pathname: panelRouteDescriptor(action.panel).pathname,
|
||||
pathname: descriptor.pathname,
|
||||
params: {
|
||||
hostId,
|
||||
worktreeId,
|
||||
name: worktreeName || '',
|
||||
// Source control's post-diff-open dismissal keys off origin: 'session' (U2).
|
||||
...(action.panel === 'sourceControl' ? { origin: 'session' } : {})
|
||||
...(action.panel === 'sourceControl' ? { origin: 'session' } : {}),
|
||||
// The PR panel routes into the hub's Pull Request segment via descriptor params.
|
||||
...descriptor.params
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { MobileSourceControlPanel } from '../../../../src/source-control/MobileSourceControlPanel'
|
||||
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
|
||||
import { parseSourceControlHubTab } from '../../../../src/source-control/mobile-source-control-hub-tab'
|
||||
|
||||
export default function MobileSourceControlScreen() {
|
||||
const params = useLocalSearchParams<{
|
||||
@@ -8,6 +9,7 @@ export default function MobileSourceControlScreen() {
|
||||
worktreeId?: string | string[]
|
||||
name?: string | string[]
|
||||
origin?: string | string[]
|
||||
tab?: string | string[]
|
||||
}>()
|
||||
return (
|
||||
<MobileSourceControlPanel
|
||||
@@ -15,6 +17,7 @@ export default function MobileSourceControlScreen() {
|
||||
worktreeId={firstParam(params.worktreeId)}
|
||||
name={firstParam(params.name)}
|
||||
origin={firstParam(params.origin)}
|
||||
initialTab={parseSourceControlHubTab(params.tab)}
|
||||
embedded={false}
|
||||
/>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,14 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
import type { ConnectionState } from '../../transport/types'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
|
||||
import { useMobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
|
||||
import {
|
||||
useMobilePrSidebarController,
|
||||
type MobilePrSidebarController
|
||||
} from '../../session/use-mobile-pr-sidebar-controller'
|
||||
import { MobilePRSidebar } from '../MobilePRSidebar'
|
||||
import { openMobilePrUrl } from '../MobilePrComposeSheet'
|
||||
|
||||
type Props = {
|
||||
type BodyProps = {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
worktreeId: string
|
||||
@@ -20,13 +23,34 @@ type Props = {
|
||||
gitStatus: MobileGitStatusResult | null
|
||||
isGithubRepo?: boolean
|
||||
branchContextLoaded?: boolean
|
||||
// Embedded (docked) drops the full-screen SafeAreaView chrome and shows a close
|
||||
// affordance; the dock column owns the safe-area insets. Full-screen otherwise.
|
||||
controller: MobilePrSidebarController
|
||||
// 'chromeless' drops the panel's own header + SafeAreaView so it can live inside
|
||||
// the source-control hub, which owns the header, segmented control, and chrome.
|
||||
chromeless?: boolean
|
||||
// When false, the parent (the hub) owns triggering the initial load — the body
|
||||
// must not also fire it, or a ?tab=pr deep link double-fetches. Default true so
|
||||
// the standalone route stays self-loading.
|
||||
autoLoad?: boolean
|
||||
embedded?: boolean
|
||||
onRequestClose?: () => void
|
||||
}
|
||||
|
||||
export function MobilePrViewPanel({
|
||||
type Props = Omit<BodyProps, 'controller' | 'autoLoad'>
|
||||
|
||||
// Standalone entry (the /pr route + wide-layout dock): owns its controller and
|
||||
// self-loads. The hub uses MobilePrViewPanelBody with a shared controller instead.
|
||||
export function MobilePrViewPanel(props: Props) {
|
||||
const controller = useMobilePrSidebarController({
|
||||
client: props.client,
|
||||
connState: props.connState,
|
||||
worktreeId: props.worktreeId,
|
||||
branch: props.branch,
|
||||
headSha: props.headSha
|
||||
})
|
||||
return <MobilePrViewPanelBody {...props} controller={controller} />
|
||||
}
|
||||
|
||||
export function MobilePrViewPanelBody({
|
||||
client,
|
||||
connState,
|
||||
worktreeId,
|
||||
@@ -35,29 +59,25 @@ export function MobilePrViewPanel({
|
||||
gitStatus,
|
||||
isGithubRepo = true,
|
||||
branchContextLoaded = true,
|
||||
controller,
|
||||
chromeless = false,
|
||||
autoLoad = true,
|
||||
embedded = false,
|
||||
onRequestClose
|
||||
}: Props) {
|
||||
}: BodyProps) {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const controller = useMobilePrSidebarController({
|
||||
client,
|
||||
connState,
|
||||
worktreeId,
|
||||
branch,
|
||||
headSha
|
||||
})
|
||||
|
||||
// A docked/full-screen PR panel is always visible — there is no drawer to open,
|
||||
// so trigger the load directly once context is ready rather than gating on the
|
||||
// showPRSidebar overlay flag (KTD4).
|
||||
// showPRSidebar overlay flag (KTD4). Skipped when the hub owns the trigger.
|
||||
const prSidebarKind = controller.prSidebarState.kind
|
||||
const refetch = controller.refetchPRSidebar
|
||||
useEffect(() => {
|
||||
if (branch && isGithubRepo && prSidebarKind === 'hidden') {
|
||||
if (autoLoad && branch && isGithubRepo && prSidebarKind === 'hidden') {
|
||||
refetch()
|
||||
}
|
||||
}, [branch, isGithubRepo, prSidebarKind, refetch])
|
||||
}, [autoLoad, branch, isGithubRepo, prSidebarKind, refetch])
|
||||
|
||||
// Embedded: the dock column applies the bottom inset; full-screen relies on its own
|
||||
// SafeAreaView (edges top only), so content must clear the home indicator itself.
|
||||
@@ -110,6 +130,11 @@ export function MobilePrViewPanel({
|
||||
/>
|
||||
)
|
||||
|
||||
// Chromeless: the hub owns the header + chrome, so render just the scrollable body.
|
||||
if (chromeless) {
|
||||
return <View style={styles.container}>{sidebar}</View>
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -86,7 +86,8 @@ describe('panelRouteDescriptor', () => {
|
||||
pathname: '/h/[hostId]/files/[worktreeId]'
|
||||
})
|
||||
expect(panelRouteDescriptor('pr')).toEqual({
|
||||
pathname: '/h/[hostId]/pr/[worktreeId]'
|
||||
pathname: '/h/[hostId]/source-control/[worktreeId]',
|
||||
params: { tab: 'pr' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,13 +47,18 @@ export function resolvePanelAction(args: {
|
||||
|
||||
// Single source of truth for each panel's expo-router pathname pattern so narrow-push
|
||||
// and any deep-linking agree; the caller supplies the [hostId]/[worktreeId] params.
|
||||
export function panelRouteDescriptor(panel: Exclude<ActivePanel, null>): { pathname: string } {
|
||||
// The Pull Request panel is a segment of the source-control hub, so its narrow-push
|
||||
// targets that route with `tab: 'pr'` rather than the standalone (redirecting) route.
|
||||
export function panelRouteDescriptor(panel: Exclude<ActivePanel, null>): {
|
||||
pathname: string
|
||||
params?: Record<string, string>
|
||||
} {
|
||||
switch (panel) {
|
||||
case 'sourceControl':
|
||||
return { pathname: '/h/[hostId]/source-control/[worktreeId]' }
|
||||
case 'files':
|
||||
return { pathname: '/h/[hostId]/files/[worktreeId]' }
|
||||
case 'pr':
|
||||
return { pathname: '/h/[hostId]/pr/[worktreeId]' }
|
||||
return { pathname: '/h/[hostId]/source-control/[worktreeId]', params: { tab: 'pr' } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,3 +145,5 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
refetchPRSidebar: load
|
||||
}
|
||||
}
|
||||
|
||||
export type MobilePrSidebarController = ReturnType<typeof useMobilePrSidebarController>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { ConnectionState, RpcSuccess } from '../transport/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
fetchMobileGitHistory,
|
||||
mapMobileCommitRows,
|
||||
type MobileCommitRow
|
||||
} from './mobile-git-history'
|
||||
import type { GitBranchChangeEntry } from '../../../src/shared/types'
|
||||
|
||||
type Props = {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
worktreeId: string
|
||||
bottomInset: number
|
||||
}
|
||||
|
||||
// Headerless commit-history list. Extracted from the /history route so the hub's
|
||||
// History segment and the standalone route render the same body over one code path.
|
||||
export function MobileGitHistoryList({ client, connState, worktreeId, bottomInset }: Props) {
|
||||
const [rows, setRows] = useState<MobileCommitRow[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!client || connState !== 'connected' || !worktreeId) {
|
||||
return
|
||||
}
|
||||
// Reset prior error/rows so a successful retry doesn't stay stuck behind a
|
||||
// stale error (error wins render precedence).
|
||||
setError(null)
|
||||
setRows(null)
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await fetchMobileGitHistory(client, worktreeId)
|
||||
if (active) {
|
||||
setRows(mapMobileCommitRows(result, Date.now()))
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load history')
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [client, connState, worktreeId])
|
||||
|
||||
const toggleCommit = useCallback(
|
||||
(row: MobileCommitRow) => {
|
||||
const next = expanded === row.id ? null : row.id
|
||||
setExpanded(next)
|
||||
if (next && client && !filesById[row.id]) {
|
||||
setFilesById((prev) => ({ ...prev, [row.id]: 'loading' }))
|
||||
void client
|
||||
.sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id })
|
||||
.then((response) => {
|
||||
const entries = response.ok
|
||||
? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries
|
||||
: []
|
||||
setFilesById((prev) => ({ ...prev, [row.id]: entries }))
|
||||
})
|
||||
.catch(() => setFilesById((prev) => ({ ...prev, [row.id]: [] })))
|
||||
}
|
||||
},
|
||||
[client, expanded, filesById, worktreeId]
|
||||
)
|
||||
|
||||
const renderCommit = useCallback(
|
||||
({ item }: { item: MobileCommitRow }) => {
|
||||
const files = filesById[item.id]
|
||||
const isOpen = expanded === item.id
|
||||
return (
|
||||
<View style={styles.commit}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.commitHeader, pressed && styles.commitHeaderPressed]}
|
||||
onPress={() => toggleCommit(item)}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={colors.textMuted} />
|
||||
)}
|
||||
<View style={styles.commitMain}>
|
||||
<Text style={styles.commitSubject} numberOfLines={1}>
|
||||
{item.subject}
|
||||
</Text>
|
||||
<Text style={styles.commitMeta} numberOfLines={1}>
|
||||
{item.shortId} · {item.author} · {item.relativeTime}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{isOpen ? (
|
||||
<View style={styles.files}>
|
||||
{files === 'loading' || files === undefined ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : files.length === 0 ? (
|
||||
<Text style={styles.empty}>No file changes</Text>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<View key={file.path} style={styles.fileRow}>
|
||||
<Text style={styles.filePath} numberOfLines={1}>
|
||||
{file.path}
|
||||
</Text>
|
||||
<Text style={styles.fileStat}>
|
||||
{file.added ? <Text style={styles.add}>+{file.added} </Text> : null}
|
||||
{file.removed ? <Text style={styles.del}>-{file.removed}</Text> : null}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
[expanded, filesById, toggleCommit]
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>{error}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (rows === null) {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>No commits.</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<FlatList
|
||||
data={rows}
|
||||
renderItem={renderCommit}
|
||||
keyExtractor={(row) => row.id}
|
||||
contentContainerStyle={{ paddingBottom: spacing.lg + bottomInset }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
|
||||
stateText: { color: colors.textMuted, fontSize: typography.bodySize },
|
||||
commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle },
|
||||
commitHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2
|
||||
},
|
||||
commitHeaderPressed: { backgroundColor: colors.bgRaised },
|
||||
commitMain: { flex: 1, minWidth: 0 },
|
||||
commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize },
|
||||
commitMeta: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily,
|
||||
marginTop: 2
|
||||
},
|
||||
files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 },
|
||||
fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
filePath: {
|
||||
flex: 1,
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily },
|
||||
add: { color: colors.gitDecorationAdded },
|
||||
del: { color: colors.gitDecorationDeleted },
|
||||
empty: { color: colors.textMuted, fontSize: typography.metaSize }
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { GitBranch } from 'lucide-react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-source-control-styles'
|
||||
import { MobileSourceControlPrChip } from './MobileSourceControlPrChip'
|
||||
import type { MobilePrChipSummary } from './mobile-pr-chip-summary'
|
||||
|
||||
type Props = {
|
||||
branchLabel: string
|
||||
syncLabel: string | null
|
||||
unstagedCount: number
|
||||
stagedCount: number
|
||||
branchCount: number
|
||||
conflictOperation: string | null
|
||||
conflictBusy: boolean
|
||||
onAbortConflict: (operation: string) => void
|
||||
// The PR chip is shown only on repos with a hosted-review remote; null hides it.
|
||||
prChip: MobilePrChipSummary | null
|
||||
onOpenPr: () => void
|
||||
}
|
||||
|
||||
// Persistent card at the top of every hub segment: branch identity, sync/counts,
|
||||
// conflict state, and the PR chip. Shared so PR/History see the same status the
|
||||
// Changes lens does without re-deriving it.
|
||||
export function MobileSourceControlBranchCard({
|
||||
branchLabel,
|
||||
syncLabel,
|
||||
unstagedCount,
|
||||
stagedCount,
|
||||
branchCount,
|
||||
conflictOperation,
|
||||
conflictBusy,
|
||||
onAbortConflict,
|
||||
prChip,
|
||||
onOpenPr
|
||||
}: Props) {
|
||||
const showConflict = conflictOperation !== null && conflictOperation !== 'unknown'
|
||||
return (
|
||||
<View style={styles.summaryCard}>
|
||||
<View style={styles.summaryHeader}>
|
||||
<View style={styles.branchLine}>
|
||||
<GitBranch size={15} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
<Text style={styles.branchText} numberOfLines={1}>
|
||||
{branchLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{syncLabel ? <Text style={styles.syncText}>{syncLabel}</Text> : null}
|
||||
</View>
|
||||
<View style={styles.countRow}>
|
||||
<Text style={styles.countText}>{unstagedCount} changed</Text>
|
||||
<Text style={styles.countText}>{stagedCount} staged</Text>
|
||||
{branchCount > 0 ? <Text style={styles.countText}>{branchCount} on branch</Text> : null}
|
||||
{showConflict ? (
|
||||
<View style={styles.conflictRow}>
|
||||
<Text style={styles.conflictText}>{conflictOperation}</Text>
|
||||
{conflictOperation === 'merge' || conflictOperation === 'rebase' ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.abortButton, pressed && styles.abortPressed]}
|
||||
disabled={conflictBusy}
|
||||
onPress={() => onAbortConflict(conflictOperation)}
|
||||
>
|
||||
<Text style={styles.abortText}>
|
||||
{conflictBusy ? 'Aborting…' : `Abort ${conflictOperation}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{prChip ? <MobileSourceControlPrChip summary={prChip} onPress={onOpenPr} /> : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActivityIndicator, Pressable, SectionList, Text, TextInput, View } from 'react-native'
|
||||
import { GitBranch, Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-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'
|
||||
@@ -7,12 +7,13 @@ import { KEYBOARD_COMMIT_BAR_CLEARANCE } from './mobile-source-control-screen-st
|
||||
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
|
||||
}
|
||||
|
||||
// The ready-state body: summary card, changed-files list, and commit bar.
|
||||
// The ready-state Changes segment body: quick actions, changed-files list, and commit bar.
|
||||
export function MobileSourceControlContent({ state }: Props) {
|
||||
const {
|
||||
insets,
|
||||
@@ -29,23 +30,17 @@ export function MobileSourceControlContent({ state }: Props) {
|
||||
keyboardLift,
|
||||
openingPath,
|
||||
openingBranchPath,
|
||||
status,
|
||||
sections,
|
||||
branchEntries,
|
||||
hasVisibleChanges,
|
||||
stageablePaths,
|
||||
unstageablePaths,
|
||||
stagedCount,
|
||||
unstagedCount,
|
||||
branchLabel,
|
||||
syncLabel,
|
||||
primaryAction,
|
||||
createPrAction,
|
||||
stageAll,
|
||||
unstageAll,
|
||||
generateCommitMessage,
|
||||
cancelGenerateCommitMessage,
|
||||
abortConflictOperation,
|
||||
openFile,
|
||||
openBranchDiff,
|
||||
runGitAction
|
||||
@@ -66,41 +61,7 @@ export function MobileSourceControlContent({ state }: Props) {
|
||||
<Text style={styles.reconnectBannerText}>Reconnecting to desktop...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.summaryCard}>
|
||||
<View style={styles.summaryHeader}>
|
||||
<View style={styles.branchLine}>
|
||||
<GitBranch size={15} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
<Text style={styles.branchText} numberOfLines={1}>
|
||||
{branchLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{syncLabel ? <Text style={styles.syncText}>{syncLabel}</Text> : null}
|
||||
</View>
|
||||
<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' ? (
|
||||
<View style={styles.conflictRow}>
|
||||
<Text style={styles.conflictText}>{status.conflictOperation}</Text>
|
||||
{(status.conflictOperation === 'merge' || status.conflictOperation === 'rebase') && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.abortButton, pressed && styles.abortPressed]}
|
||||
disabled={busyAction !== null}
|
||||
onPress={() => void abortConflictOperation(status.conflictOperation)}
|
||||
>
|
||||
<Text style={styles.abortText}>
|
||||
{busyAction === `abort-${status.conflictOperation}`
|
||||
? 'Aborting…'
|
||||
: `Abort ${status.conflictOperation}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={hubStyles.changesControls}>
|
||||
{commitFailureRecovery ? (
|
||||
<MobileCommitFailurePanel
|
||||
failure={commitFailureRecovery}
|
||||
@@ -170,6 +131,7 @@ export function MobileSourceControlContent({ state }: Props) {
|
||||
</View>
|
||||
) : (
|
||||
<SectionList
|
||||
style={hubStyles.tabBody}
|
||||
sections={sections}
|
||||
renderItem={makeRenderFileRow({
|
||||
busyAction,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, 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'
|
||||
@@ -6,7 +7,15 @@ import { useMobileSourceControlActionSheet } from './use-mobile-source-control-a
|
||||
import { MobileSourceControlHeader } from './MobileSourceControlHeader'
|
||||
import { MobileSourceControlContent } from './MobileSourceControlContent'
|
||||
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 { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
|
||||
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
|
||||
|
||||
export type MobileSourceControlPanelProps = {
|
||||
hostId: string
|
||||
@@ -15,6 +24,8 @@ export type MobileSourceControlPanelProps = {
|
||||
/** Where the panel was launched from; drives the file-open dismissal path. */
|
||||
origin?: string
|
||||
embedded?: boolean
|
||||
/** Initial hub segment (from the route's `tab` deep-link param). */
|
||||
initialTab?: SourceControlHubTab
|
||||
onRequestClose?: () => void
|
||||
onFileOpenStart?: () => void
|
||||
onOpenedFileDiff?: (relativePath: string) => void
|
||||
@@ -26,10 +37,15 @@ export function MobileSourceControlPanel({
|
||||
name = '',
|
||||
origin = '',
|
||||
embedded = false,
|
||||
initialTab = 'changes',
|
||||
onRequestClose,
|
||||
onFileOpenStart,
|
||||
onOpenedFileDiff
|
||||
}: MobileSourceControlPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<SourceControlHubTab>(initialTab)
|
||||
const openHistoryTab = useCallback(() => setActiveTab('history'), [])
|
||||
const openPrTab = useCallback(() => setActiveTab('pr'), [])
|
||||
|
||||
const state = useMobileSourceControlState({
|
||||
hostId,
|
||||
worktreeId,
|
||||
@@ -38,12 +54,15 @@ export function MobileSourceControlPanel({
|
||||
embedded,
|
||||
onRequestClose,
|
||||
onFileOpenStart,
|
||||
onOpenedFileDiff
|
||||
onOpenedFileDiff,
|
||||
onOpenHistory: openHistoryTab
|
||||
})
|
||||
const actionSheetActions = useMobileSourceControlActionSheet(state)
|
||||
const {
|
||||
client,
|
||||
connState,
|
||||
forceReconnect,
|
||||
insets,
|
||||
router,
|
||||
setRootRef,
|
||||
worktreeLabel,
|
||||
@@ -51,14 +70,53 @@ export function MobileSourceControlPanel({
|
||||
busyAction,
|
||||
openingPath,
|
||||
openingBranchPath,
|
||||
loadStatus
|
||||
loadStatus,
|
||||
status,
|
||||
branchLabel,
|
||||
syncLabel,
|
||||
unstagedCount,
|
||||
stagedCount,
|
||||
branchEntries,
|
||||
abortConflictOperation
|
||||
} = state
|
||||
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
|
||||
const ready = screenState.kind === 'ready'
|
||||
|
||||
// One PR controller feeds both the branch-card chip and the Pull Request
|
||||
// segment, so the chip's rollup can never disagree with the checks list it
|
||||
// links to. Branch + head come from the already-loaded git.status — no second
|
||||
// status read. The chip loads independently, so it never blocks the file list.
|
||||
const prBranch = status?.branch ?? null
|
||||
const prHeadSha = status?.head ?? null
|
||||
const prController = useMobilePrSidebarController({
|
||||
client,
|
||||
connState,
|
||||
worktreeId,
|
||||
branch: prBranch,
|
||||
headSha: prHeadSha
|
||||
})
|
||||
const isHostedRepo = prController.prSidebarIsGithubRepo
|
||||
const prSidebarKind = prController.prSidebarState.kind
|
||||
const refetchPr = prController.refetchPRSidebar
|
||||
useEffect(() => {
|
||||
if (prBranch && isHostedRepo && prSidebarKind === 'hidden') {
|
||||
refetchPr()
|
||||
}
|
||||
}, [prBranch, isHostedRepo, prSidebarKind, refetchPr])
|
||||
|
||||
const prChip = useMemo(() => {
|
||||
if (!isHostedRepo) {
|
||||
return null
|
||||
}
|
||||
const commentCount =
|
||||
prController.prSidebarState.kind === 'ready'
|
||||
? countUnresolvedReviewThreads(prController.prSidebarState.data.details?.comments)
|
||||
: null
|
||||
return buildMobilePrChipSummary(prController.prSidebarState, commentCount)
|
||||
}, [isHostedRepo, prController.prSidebarState])
|
||||
|
||||
// Embedded mode docks beside the terminal: close the dock instead of popping
|
||||
// a route, and skip the full-screen safe-area chrome (the dock column owns it).
|
||||
// Fall back to router.back() when embedded without a close handler so the button
|
||||
// never silently no-ops.
|
||||
const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back()
|
||||
const header = (
|
||||
<MobileSourceControlHeader
|
||||
@@ -80,6 +138,8 @@ export function MobileSourceControlPanel({
|
||||
</SafeAreaView>
|
||||
)}
|
||||
|
||||
<MobileSourceControlSegments active={activeTab} onSelect={setActiveTab} />
|
||||
|
||||
{screenState.kind === 'loading' ? (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
@@ -110,7 +170,46 @@ export function MobileSourceControlPanel({
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<MobileSourceControlContent state={state} />
|
||||
<>
|
||||
<MobileSourceControlBranchCard
|
||||
branchLabel={branchLabel}
|
||||
syncLabel={syncLabel}
|
||||
unstagedCount={unstagedCount}
|
||||
stagedCount={stagedCount}
|
||||
branchCount={branchEntries.length}
|
||||
conflictOperation={status?.conflictOperation ?? null}
|
||||
conflictBusy={busyAction !== null}
|
||||
onAbortConflict={(operation) => void abortConflictOperation(operation)}
|
||||
prChip={prChip}
|
||||
onOpenPr={openPrTab}
|
||||
/>
|
||||
{activeTab === 'changes' ? (
|
||||
<MobileSourceControlContent state={state} />
|
||||
) : activeTab === 'pr' ? (
|
||||
<MobilePrViewPanelBody
|
||||
client={client}
|
||||
connState={connState}
|
||||
worktreeId={worktreeId}
|
||||
branch={prBranch}
|
||||
headSha={prHeadSha}
|
||||
gitStatus={status}
|
||||
isGithubRepo={isHostedRepo}
|
||||
branchContextLoaded={ready}
|
||||
controller={prController}
|
||||
chromeless
|
||||
autoLoad={false}
|
||||
/>
|
||||
) : (
|
||||
<View style={hubStyles.tabBody}>
|
||||
<MobileGitHistoryList
|
||||
client={client}
|
||||
connState={connState}
|
||||
worktreeId={worktreeId}
|
||||
bottomInset={insets.bottom}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MobileSourceControlModals state={state} actionSheetActions={actionSheetActions} />
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleDot,
|
||||
GitPullRequest,
|
||||
MessageSquare,
|
||||
X
|
||||
} from 'lucide-react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { statusColor } from '../components/pr-sidebar/pr-sidebar-status-color'
|
||||
import { hubStyles } from './mobile-source-control-hub-styles'
|
||||
import type { MobilePrChipRollup, MobilePrChipSummary } from './mobile-pr-chip-summary'
|
||||
|
||||
type Props = {
|
||||
summary: MobilePrChipSummary
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
// The glanceable PR status line on the branch card. Tapping it switches to the
|
||||
// Pull Request segment. Rendered only when the repo supports hosted review — the
|
||||
// parent gates on that, so this component always has something meaningful to show.
|
||||
export function MobileSourceControlPrChip({ summary, onPress }: Props) {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [hubStyles.chip, pressed && hubStyles.chipPressed]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={chipAccessibilityLabel(summary)}
|
||||
>
|
||||
<View style={hubStyles.chipIcon}>
|
||||
<GitPullRequest size={15} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</View>
|
||||
{summary.kind === 'loading' ? (
|
||||
<>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<Text style={hubStyles.chipMutedText} numberOfLines={1}>
|
||||
Loading pull request…
|
||||
</Text>
|
||||
</>
|
||||
) : summary.kind === 'none' ? (
|
||||
<>
|
||||
<Text style={hubStyles.chipCreateText}>Create pull request</Text>
|
||||
<View style={hubStyles.chipSpacer} />
|
||||
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
|
||||
</>
|
||||
) : summary.kind === 'unavailable' ? (
|
||||
<>
|
||||
<Text style={hubStyles.chipMutedText} numberOfLines={1}>
|
||||
{summary.message}
|
||||
</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={hubStyles.chipNumber}>#{summary.number}</Text>
|
||||
<View style={[hubStyles.statePill, { borderColor: statusColor(summary.stateToken) }]}>
|
||||
<Text style={[hubStyles.statePillText, { color: statusColor(summary.stateToken) }]}>
|
||||
{summary.stateLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<ChipRollup rollup={summary.rollup} />
|
||||
{summary.commentCount && summary.commentCount > 0 ? (
|
||||
<View style={hubStyles.comment}>
|
||||
<MessageSquare size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
<Text style={hubStyles.commentText}>{summary.commentCount}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={hubStyles.chipSpacer} />
|
||||
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function ChipRollup({ rollup }: { rollup: MobilePrChipRollup }) {
|
||||
const color = statusColor(rollup.token)
|
||||
return (
|
||||
<View style={hubStyles.rollup}>
|
||||
<RollupIcon kind={rollup.kind} color={color} />
|
||||
<Text style={[hubStyles.rollupText, { color }]}>{rollup.text}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function RollupIcon({ kind, color }: { kind: MobilePrChipRollup['kind']; color: string }) {
|
||||
const size = 13
|
||||
const strokeWidth = 2.3
|
||||
switch (kind) {
|
||||
case 'conflict':
|
||||
return <AlertTriangle size={size} color={color} strokeWidth={strokeWidth} />
|
||||
case 'failing':
|
||||
return <X size={size} color={color} strokeWidth={strokeWidth} />
|
||||
case 'running':
|
||||
return <CircleDot size={size} color={color} strokeWidth={strokeWidth} />
|
||||
case 'passed':
|
||||
return <Check size={size} color={color} strokeWidth={strokeWidth} />
|
||||
case 'none':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function chipAccessibilityLabel(summary: MobilePrChipSummary): string {
|
||||
switch (summary.kind) {
|
||||
case 'loading':
|
||||
return 'Loading pull request'
|
||||
case 'none':
|
||||
return 'Create pull request'
|
||||
case 'unavailable':
|
||||
return `Pull request unavailable: ${summary.message}`
|
||||
case 'ready': {
|
||||
const comments =
|
||||
summary.commentCount && summary.commentCount > 0
|
||||
? `, ${summary.commentCount} unresolved comments`
|
||||
: ''
|
||||
return `Pull request ${summary.number}, ${summary.stateLabel}, ${summary.rollup.text}${comments}. Open pull request.`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import {
|
||||
SOURCE_CONTROL_HUB_TABS,
|
||||
SOURCE_CONTROL_HUB_TAB_LABELS,
|
||||
type SourceControlHubTab
|
||||
} from './mobile-source-control-hub-tab'
|
||||
import { hubStyles } from './mobile-source-control-hub-styles'
|
||||
|
||||
type Props = {
|
||||
active: SourceControlHubTab
|
||||
onSelect: (tab: SourceControlHubTab) => void
|
||||
}
|
||||
|
||||
// The hub's top-level lens switcher. Switching is local state (no route push) so
|
||||
// scroll position and the shared branch card persist across Changes/PR/History.
|
||||
export function MobileSourceControlSegments({ active, onSelect }: Props) {
|
||||
return (
|
||||
<View style={hubStyles.segments} accessibilityRole="tablist">
|
||||
{SOURCE_CONTROL_HUB_TABS.map((tab) => {
|
||||
const isActive = tab === active
|
||||
return (
|
||||
<Pressable
|
||||
key={tab}
|
||||
style={({ pressed }) => [
|
||||
hubStyles.segment,
|
||||
isActive && hubStyles.segmentActive,
|
||||
pressed && !isActive && hubStyles.segmentPressed
|
||||
]}
|
||||
onPress={() => onSelect(tab)}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: isActive }}
|
||||
accessibilityLabel={SOURCE_CONTROL_HUB_TAB_LABELS[tab]}
|
||||
>
|
||||
<Text
|
||||
style={[hubStyles.segmentText, isActive && hubStyles.segmentTextActive]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{SOURCE_CONTROL_HUB_TAB_LABELS[tab]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { PRCheckDetail, PRComment, PRInfo } from '../../../src/shared/types'
|
||||
import type { PrSidebarState } from '../session/mobile-pr-sidebar-state'
|
||||
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
|
||||
|
||||
function pr(overrides: Partial<PRInfo> = {}): PRInfo {
|
||||
return {
|
||||
number: 7701,
|
||||
title: 'Make center split more visible',
|
||||
state: 'open',
|
||||
url: 'https://example.test/pr/7701',
|
||||
checksStatus: 'success',
|
||||
updatedAt: '2026-07-07T00:00:00Z',
|
||||
mergeable: 'MERGEABLE',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function check(
|
||||
conclusion: PRCheckDetail['conclusion'],
|
||||
status: PRCheckDetail['status'] = 'completed'
|
||||
): PRCheckDetail {
|
||||
return { name: `check-${conclusion}-${status}`, status, conclusion, url: null }
|
||||
}
|
||||
|
||||
function ready(prInfo: PRInfo, checks: PRCheckDetail[]): PrSidebarState {
|
||||
return { kind: 'ready', data: { pr: prInfo, checks, details: null } }
|
||||
}
|
||||
|
||||
describe('buildMobilePrChipSummary', () => {
|
||||
it('maps non-ready states', () => {
|
||||
expect(buildMobilePrChipSummary({ kind: 'hidden' })).toEqual({ kind: 'loading' })
|
||||
expect(buildMobilePrChipSummary({ kind: 'loading' })).toEqual({ kind: 'loading' })
|
||||
expect(buildMobilePrChipSummary({ kind: 'none' })).toEqual({ kind: 'none' })
|
||||
expect(buildMobilePrChipSummary({ kind: 'error', message: 'net' })).toEqual({
|
||||
kind: 'unavailable',
|
||||
message: 'net'
|
||||
})
|
||||
expect(buildMobilePrChipSummary({ kind: 'blocked', message: 'auth' })).toEqual({
|
||||
kind: 'unavailable',
|
||||
message: 'auth'
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces the PR number and state badge', () => {
|
||||
const summary = buildMobilePrChipSummary(ready(pr({ state: 'draft' }), [check('success')]))
|
||||
expect(summary.kind).toBe('ready')
|
||||
if (summary.kind !== 'ready') {
|
||||
return
|
||||
}
|
||||
expect(summary.number).toBe(7701)
|
||||
expect(summary.stateLabel).toBe('Draft')
|
||||
})
|
||||
|
||||
it('rolls up passed checks as passed/total', () => {
|
||||
const summary = buildMobilePrChipSummary(
|
||||
ready(pr(), [check('success'), check('success'), check('skipped')])
|
||||
)
|
||||
if (summary.kind !== 'ready') {
|
||||
throw new Error('expected ready')
|
||||
}
|
||||
expect(summary.rollup).toEqual({ kind: 'passed', text: '2/3', token: 'statusGreen' })
|
||||
})
|
||||
|
||||
it('prefers failing over running and passing', () => {
|
||||
const summary = buildMobilePrChipSummary(
|
||||
ready(pr(), [check('success'), check('failure'), check(null, 'in_progress')])
|
||||
)
|
||||
if (summary.kind !== 'ready') {
|
||||
throw new Error('expected ready')
|
||||
}
|
||||
expect(summary.rollup).toEqual({ kind: 'failing', text: '1 failing', token: 'statusRed' })
|
||||
})
|
||||
|
||||
it('shows running when nothing has failed yet', () => {
|
||||
const summary = buildMobilePrChipSummary(ready(pr(), [check('success'), check(null, 'queued')]))
|
||||
if (summary.kind !== 'ready') {
|
||||
throw new Error('expected ready')
|
||||
}
|
||||
expect(summary.rollup).toEqual({ kind: 'running', text: '1 running', token: 'statusAmber' })
|
||||
})
|
||||
|
||||
it('lets a merge conflict win over green checks', () => {
|
||||
const summary = buildMobilePrChipSummary(
|
||||
ready(pr({ mergeable: 'CONFLICTING' }), [check('success')])
|
||||
)
|
||||
if (summary.kind !== 'ready') {
|
||||
throw new Error('expected ready')
|
||||
}
|
||||
expect(summary.rollup.kind).toBe('conflict')
|
||||
})
|
||||
|
||||
it('reports no checks when the list is empty', () => {
|
||||
const summary = buildMobilePrChipSummary(ready(pr(), []))
|
||||
if (summary.kind !== 'ready') {
|
||||
throw new Error('expected ready')
|
||||
}
|
||||
expect(summary.rollup).toEqual({ kind: 'none', text: 'No checks', token: 'textSecondary' })
|
||||
})
|
||||
|
||||
it('passes through the unresolved comment count', () => {
|
||||
const summary = buildMobilePrChipSummary(ready(pr(), [check('success')]), 3)
|
||||
if (summary.kind !== 'ready') {
|
||||
throw new Error('expected ready')
|
||||
}
|
||||
expect(summary.commentCount).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('countUnresolvedReviewThreads', () => {
|
||||
function comment(overrides: Partial<PRComment>): PRComment {
|
||||
return {
|
||||
id: 1,
|
||||
author: 'octocat',
|
||||
authorAvatarUrl: '',
|
||||
body: 'x',
|
||||
createdAt: '2026-07-07T00:00:00Z',
|
||||
url: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
it('returns null when details have not loaded', () => {
|
||||
expect(countUnresolvedReviewThreads(null)).toBeNull()
|
||||
expect(countUnresolvedReviewThreads(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('counts each unresolved thread once and ignores resolved threads', () => {
|
||||
const comments = [
|
||||
comment({ id: 1, threadId: 't1', isResolved: false }),
|
||||
comment({ id: 2, threadId: 't1', isResolved: false }),
|
||||
comment({ id: 3, threadId: 't2', isResolved: true }),
|
||||
comment({ id: 4, threadId: 't3' }),
|
||||
comment({ id: 5 })
|
||||
]
|
||||
expect(countUnresolvedReviewThreads(comments)).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { PRComment } from '../../../src/shared/types'
|
||||
import type { PrSidebarState } from '../session/mobile-pr-sidebar-state'
|
||||
import {
|
||||
prStateBadge,
|
||||
summarizePRChecks,
|
||||
type MobileStatusToken
|
||||
} from '../components/pr-sidebar/pr-checks-presentation'
|
||||
|
||||
// Pure derivation of the branch-card PR chip from the shared PR sidebar state.
|
||||
// No React/native imports so the rollup precedence is unit-testable (KTD5). The
|
||||
// chip and the Pull Request segment read the SAME PrSidebarState, so the chip's
|
||||
// check rollup can never disagree with the checks list it links to.
|
||||
|
||||
// The single check-rollup token the chip shows. Exactly one wins, by precedence:
|
||||
// merge conflict > failing > running > passed > no checks. Worst-actionable-first
|
||||
// so a red/amber signal is never hidden behind a green count.
|
||||
export type MobilePrChipRollup =
|
||||
| { kind: 'conflict'; text: string; token: MobileStatusToken }
|
||||
| { kind: 'failing'; text: string; token: MobileStatusToken }
|
||||
| { kind: 'running'; text: string; token: MobileStatusToken }
|
||||
| { kind: 'passed'; text: string; token: MobileStatusToken }
|
||||
| { kind: 'none'; text: string; token: MobileStatusToken }
|
||||
|
||||
export type MobilePrChipSummary =
|
||||
| { kind: 'loading' }
|
||||
// GitHub repo, but this branch has no open/linked PR — the chip becomes a
|
||||
// "create pull request" affordance that routes to the PR segment's composer.
|
||||
| { kind: 'none' }
|
||||
// A permanent (auth/permission) or transient failure loading PR data. The chip
|
||||
// degrades to a muted, tappable line rather than vanishing.
|
||||
| { kind: 'unavailable'; message: string }
|
||||
| {
|
||||
kind: 'ready'
|
||||
number: number
|
||||
stateLabel: string
|
||||
stateToken: MobileStatusToken
|
||||
rollup: MobilePrChipRollup
|
||||
// Unresolved review-comment count, when the deferred details payload has
|
||||
// loaded; null while it is still loading or unavailable.
|
||||
commentCount: number | null
|
||||
}
|
||||
|
||||
// Unresolved review threads (the "💬 n" count). Counts each inline thread once by
|
||||
// threadId when it is not resolved; top-level conversation comments (no threadId)
|
||||
// are not review threads and don't count. Returns null when details haven't loaded.
|
||||
export function countUnresolvedReviewThreads(
|
||||
comments: PRComment[] | null | undefined
|
||||
): number | null {
|
||||
if (!comments) {
|
||||
return null
|
||||
}
|
||||
const unresolved = new Set<string>()
|
||||
for (const comment of comments) {
|
||||
if (comment.threadId && comment.isResolved !== true) {
|
||||
unresolved.add(comment.threadId)
|
||||
}
|
||||
}
|
||||
return unresolved.size
|
||||
}
|
||||
|
||||
export function buildMobilePrChipSummary(
|
||||
state: PrSidebarState,
|
||||
commentCount: number | null = null
|
||||
): MobilePrChipSummary {
|
||||
switch (state.kind) {
|
||||
case 'hidden':
|
||||
case 'loading':
|
||||
return { kind: 'loading' }
|
||||
case 'none':
|
||||
return { kind: 'none' }
|
||||
case 'error':
|
||||
case 'blocked':
|
||||
return { kind: 'unavailable', message: state.message }
|
||||
case 'ready': {
|
||||
const badge = prStateBadge(state.data.pr.state)
|
||||
return {
|
||||
kind: 'ready',
|
||||
number: state.data.pr.number,
|
||||
stateLabel: badge.label,
|
||||
stateToken: badge.token,
|
||||
rollup: buildChipRollup(state),
|
||||
commentCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildChipRollup(state: Extract<PrSidebarState, { kind: 'ready' }>): MobilePrChipRollup {
|
||||
// Conflicts win: the checks may be green, but the PR still can't merge.
|
||||
if (state.data.pr.mergeable === 'CONFLICTING') {
|
||||
return { kind: 'conflict', text: 'Conflicts', token: 'statusAmber' }
|
||||
}
|
||||
const checks = summarizePRChecks(state.data.checks)
|
||||
if (checks.failed > 0) {
|
||||
return { kind: 'failing', text: `${checks.failed} failing`, token: 'statusRed' }
|
||||
}
|
||||
if (checks.pending > 0) {
|
||||
return { kind: 'running', text: `${checks.pending} running`, token: 'statusAmber' }
|
||||
}
|
||||
if (checks.passed > 0) {
|
||||
return { kind: 'passed', text: `${checks.passed}/${checks.total}`, token: 'statusGreen' }
|
||||
}
|
||||
return { kind: 'none', text: 'No checks', token: 'textSecondary' }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
// Styles for the hub's segmented control and the branch-card PR chip. Split from
|
||||
// mobile-source-control-styles.ts so neither file crosses the line limit.
|
||||
export const hubStyles = StyleSheet.create({
|
||||
segments: {
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
padding: 3,
|
||||
borderRadius: radii.button + 2,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
gap: 2
|
||||
},
|
||||
segment: {
|
||||
flex: 1,
|
||||
minHeight: 34,
|
||||
borderRadius: radii.button,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
segmentActive: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
segmentPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
segmentText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
segmentTextActive: {
|
||||
color: colors.textPrimary
|
||||
},
|
||||
// The PR chip sits below the count row inside the branch card, separated by a
|
||||
// hairline so it reads as a distinct, tappable status line.
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.borderSubtle
|
||||
},
|
||||
chipPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
chipIcon: {
|
||||
width: 18,
|
||||
alignItems: 'center'
|
||||
},
|
||||
chipNumber: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '700'
|
||||
},
|
||||
statePill: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 1,
|
||||
borderRadius: radii.button,
|
||||
borderWidth: StyleSheet.hairlineWidth
|
||||
},
|
||||
statePillText: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '700'
|
||||
},
|
||||
rollup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
},
|
||||
rollupText: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
comment: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
},
|
||||
commentText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
// Pushes the chevron to the trailing edge without a fixed-width spacer.
|
||||
chipSpacer: {
|
||||
flex: 1,
|
||||
minWidth: spacing.sm
|
||||
},
|
||||
chipCreateText: {
|
||||
color: colors.accentBlue,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
chipMutedText: {
|
||||
flex: 1,
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
// Wraps the Changes-only controls (commit-failure/error notice, create-PR entry,
|
||||
// bulk Stage/Unstage row) that used to live inside the summary card, now that the
|
||||
// card is shared across segments and holds only branch status.
|
||||
changesControls: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
// Fills the remaining space below the header/segments/card so each segment's
|
||||
// scroll view (SectionList / PR sidebar / history list) expands and scrolls.
|
||||
tabBody: {
|
||||
flex: 1
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseSourceControlHubTab, SOURCE_CONTROL_HUB_TABS } from './mobile-source-control-hub-tab'
|
||||
|
||||
describe('parseSourceControlHubTab', () => {
|
||||
it('accepts each known tab', () => {
|
||||
for (const tab of SOURCE_CONTROL_HUB_TABS) {
|
||||
expect(parseSourceControlHubTab(tab)).toBe(tab)
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the first element of an array param', () => {
|
||||
expect(parseSourceControlHubTab(['pr', 'history'])).toBe('pr')
|
||||
})
|
||||
|
||||
it('falls back to changes for unknown, empty, or missing values', () => {
|
||||
expect(parseSourceControlHubTab('nope')).toBe('changes')
|
||||
expect(parseSourceControlHubTab('')).toBe('changes')
|
||||
expect(parseSourceControlHubTab(undefined)).toBe('changes')
|
||||
expect(parseSourceControlHubTab(null)).toBe('changes')
|
||||
expect(parseSourceControlHubTab([])).toBe('changes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
// The three lenses of the mobile Source Control hub. Kept as a pure module (no
|
||||
// React/native imports) so tab parsing is unit-testable and the deep-link `tab`
|
||||
// query param and the segmented control share one source of truth.
|
||||
|
||||
export type SourceControlHubTab = 'changes' | 'pr' | 'history'
|
||||
|
||||
export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [
|
||||
'changes',
|
||||
'pr',
|
||||
'history'
|
||||
] as const
|
||||
|
||||
export const SOURCE_CONTROL_HUB_TAB_LABELS: Record<SourceControlHubTab, string> = {
|
||||
changes: 'Changes',
|
||||
pr: 'Pull Request',
|
||||
history: 'History'
|
||||
}
|
||||
|
||||
// Normalize a route param (possibly an array from expo-router, possibly unknown)
|
||||
// to a valid tab, defaulting to 'changes'. Deep links that name a stale/invalid
|
||||
// tab fall back rather than render a blank body.
|
||||
export function parseSourceControlHubTab(
|
||||
value: string | string[] | undefined | null
|
||||
): SourceControlHubTab {
|
||||
const first = Array.isArray(value) ? value[0] : value
|
||||
return SOURCE_CONTROL_HUB_TABS.includes(first as SourceControlHubTab)
|
||||
? (first as SourceControlHubTab)
|
||||
: 'changes'
|
||||
}
|
||||
@@ -45,6 +45,8 @@ type Params = {
|
||||
setCreatedPrUrl: (next: string | null) => void
|
||||
setCreatedPrWarning: (next: string | null) => void
|
||||
recordCommitFailure: RecordMobileCommitFailure
|
||||
// Hub override: switch to the History segment instead of pushing the route.
|
||||
onOpenHistory?: () => void
|
||||
}
|
||||
|
||||
// All git workflow + action-sheet runners for the source-control panel. Split
|
||||
@@ -78,7 +80,8 @@ export function useMobileSourceControlRunners(params: Params) {
|
||||
setShowBranchPicker,
|
||||
setCreatedPrUrl,
|
||||
setCreatedPrWarning,
|
||||
recordCommitFailure
|
||||
recordCommitFailure,
|
||||
onOpenHistory
|
||||
} = params
|
||||
|
||||
const runGitWorkflow = useCallback(
|
||||
@@ -248,6 +251,12 @@ export function useMobileSourceControlRunners(params: Params) {
|
||||
|
||||
const openHistory = useCallback(() => {
|
||||
setShowActionSheet(false)
|
||||
// Inside the hub, History is a segment — switch to it rather than pushing a
|
||||
// route. The standalone/dock usage (no override) keeps pushing the route.
|
||||
if (onOpenHistory) {
|
||||
onOpenHistory()
|
||||
return
|
||||
}
|
||||
if (hostId && worktreeId) {
|
||||
router.push(
|
||||
`/h/${hostId}/history/${encodeURIComponent(worktreeId)}` as Parameters<
|
||||
@@ -255,7 +264,7 @@ export function useMobileSourceControlRunners(params: Params) {
|
||||
>[0]
|
||||
)
|
||||
}
|
||||
}, [hostId, router, setShowActionSheet, worktreeId])
|
||||
}, [hostId, onOpenHistory, router, setShowActionSheet, worktreeId])
|
||||
|
||||
// Switch to a local branch, then reload status.
|
||||
const checkoutBranch = useCallback(
|
||||
|
||||
@@ -42,6 +42,9 @@ export type MobileSourceControlStateParams = {
|
||||
onRequestClose?: () => void
|
||||
onFileOpenStart?: () => void
|
||||
onOpenedFileDiff?: (relativePath: string) => void
|
||||
// When the panel runs inside the hub, "History" switches the segment instead of
|
||||
// pushing the standalone route. Absent for the standalone/dock usage.
|
||||
onOpenHistory?: () => void
|
||||
}
|
||||
|
||||
export function useMobileSourceControlState(params: MobileSourceControlStateParams) {
|
||||
@@ -53,7 +56,8 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
|
||||
embedded,
|
||||
onRequestClose,
|
||||
onFileOpenStart,
|
||||
onOpenedFileDiff
|
||||
onOpenedFileDiff,
|
||||
onOpenHistory
|
||||
} = params
|
||||
const insets = useSafeAreaInsets()
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
@@ -197,7 +201,8 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
|
||||
setShowBranchPicker,
|
||||
setCreatedPrUrl,
|
||||
setCreatedPrWarning,
|
||||
recordCommitFailure
|
||||
recordCommitFailure,
|
||||
onOpenHistory
|
||||
})
|
||||
const createPrAction = useMobileSourceControlCreatePrAction({
|
||||
client,
|
||||
|
||||
Reference in New Issue
Block a user