diff --git a/mobile/app/h/[hostId]/history/[worktreeId].tsx b/mobile/app/h/[hostId]/history/[worktreeId].tsx index ce7309eff0f..60fc5205dcf 100644 --- a/mobile/app/h/[hostId]/history/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/history/[worktreeId].tsx @@ -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(null) - const [error, setError] = useState(null) - const [reloadNonce, setReloadNonce] = useState(0) - const [expanded, setExpanded] = useState(null) - const [filesById, setFilesById] = useState>({}) - - 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 ( - - [styles.commitHeader, pressed && styles.commitHeaderPressed]} - onPress={() => toggleCommit(item)} - > - {isOpen ? ( - - ) : ( - - )} - - - {item.subject} - - - {item.shortId} · {item.author} · {item.relativeTime} - - - - {isOpen ? ( - - {files === 'loading' || files === undefined ? ( - - ) : files.length === 0 ? ( - No file changes - ) : ( - files.map((file) => ( - - - {file.path} - - - {file.added ? +{file.added} : null} - {file.removed ? -{file.removed} : null} - - - )) - )} - - ) : null} - - ) - }, - [expanded, filesById, toggleCommit] - ) - - const view = resolveMobileHistoryScreenView({ - connected: client !== null && connState === 'connected', - rows, - error - }) - return ( - - - router.back()} accessibilityLabel="Back"> - - - Commit History - - {view.kind === 'error' || view.kind === 'waiting' ? ( - - - {view.kind === 'waiting' ? 'Waiting for desktop...' : view.message} - - - Retry - - - ) : view.kind === 'loading' ? ( - - - - ) : view.kind === 'empty' ? ( - - No commits. - - ) : ( - row.id} - contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }} - /> - )} - + ) } - -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 } -}) diff --git a/mobile/app/h/[hostId]/pr/[worktreeId].tsx b/mobile/app/h/[hostId]/pr/[worktreeId].tsx index 2e0530b0f4b..6e17b41019b 100644 --- a/mobile/app/h/[hostId]/pr/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/pr/[worktreeId].tsx @@ -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 ( - ) } diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index b556170b4c7..95aa8d7384e 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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 } }) } diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx index b9346eecaa5..1de11662dd8 100644 --- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx @@ -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 ( ) diff --git a/mobile/mock-source-control-hub.html b/mobile/mock-source-control-hub.html new file mode 100644 index 00000000000..8b09552ac7a --- /dev/null +++ b/mobile/mock-source-control-hub.html @@ -0,0 +1,1855 @@ + + + + + + Orca Mobile – Source Control Hub + + + +
+ +
+ 9:41 + + + + + + + + + + + + + +
+ + +
+ +
+

Source Control

+
make-center-split-more-visible
+
+ +
+ + +
+ + + +
+ + +
+
+ + + + + + + make-center-split-more-visible +
+
+ + ↑2 + ↓0 + + 3 changed · 1 staged +
+
+
+ + + + + + #7701 + Open + + ✓ 12/12 + 💬 3 + + + + + + +
+
+ + +
+ +
+
+ +
+ Staged Changes + 1 + + +
+
+
+ ↩ + Unstage +
+
+ 🗑 + Discard +
+
+ + + + M + + + + + EditorFileTab.tsx + + ‎src/renderer/src/components/tab-bar/ + +12−4 + + + + + +
+
+ + +
+ Changes + 3 + + +
+
+
+Stage
+
🗑Discard
+
+ + M + + + CenterSplitDivider.tsx + + ‎src/renderer/src/components/split-view/ + +34−8 + + + +
+
+
+
+Stage
+
🗑Discard
+
+ + M + + + split-view-geometry.ts + + ‎src/renderer/src/lib/ + +6−2 + + + +
+
+
+
+Stage
+
🗑Discard
+
+ + A + + + SplitDividerHandle.test.tsx + + ‎src/renderer/src/components/split-view/__tests__/ + +48−0 + + + +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + +
+ 0 selected + + + +
+
+ + +
+
+
+ +
+
+ Open + #7701 + octocat wants to merge into main +
+
Make center split divider more visible
+
make-center-split-more-visible → main · updated 20m ago
+
+ + +
+ 1 check failing + + +
+
+ + + e2e / playwright (ubuntu-latest) + Failing · 1 test · 2m 14s + + +
+ + +
+
+ Merge method + Squash and merge + + +
+
+
+
Auto-merge when checks pass
+
Merges automatically once required checks are green
+
+
+
+ +
1 failing check must pass before merging
+
+ + +
+ Unresolved comments + · 3 + + +
+
+ R + + rauchg · 18m ago + Can we clamp the divider width so it doesn't overlap the gutter on narrow panes? + CenterSplitDivider.tsx:42 + +
+
+ M + + maren · 25m ago + Add a test for the min-width geometry branch. + split-view-geometry.ts:16 + +
+ + +
+ Reviewers +
+
+ O + octocat + Approved +
+
+ D + dstndev + Review requested +
+ + +
+ All checks + · 11 passed · 1 failing + + +
+
+ + build / typecheckPassed · 1m 02s +
+
+ + lint / oxlintPassed · 38s +
+
+
+
+ + +
+
+
+
+
+
+
Make center split divider more visible
+
a1b2c3d · Jinjing · 18m ago
+
+
+
+
+
+
Add split-view geometry helper
+
9f8e7d6 · Jinjing · 42m ago
+
+
+ +
+ 2 commits ahead of origin/main + +
+ +
+
+
+
Merge pull request #7699 from orca/main
+
0f7658d · web-flow · 3h ago
+
+
+
+
+
+
Update README downloads badge
+
2dad1f0 · Jinjing · 5h ago
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+ + + + diff --git a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx index 2b9a9c78bca..91cac6f2fe8 100644 --- a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx +++ b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx @@ -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 + +// 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 +} + +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 {sidebar} + } + if (embedded) { return ( diff --git a/mobile/src/session/session-panel-host.test.ts b/mobile/src/session/session-panel-host.test.ts index 1028e489821..fb2845143d1 100644 --- a/mobile/src/session/session-panel-host.test.ts +++ b/mobile/src/session/session-panel-host.test.ts @@ -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' } }) }) }) diff --git a/mobile/src/session/session-panel-host.ts b/mobile/src/session/session-panel-host.ts index 280c368c543..fdf08602752 100644 --- a/mobile/src/session/session-panel-host.ts +++ b/mobile/src/session/session-panel-host.ts @@ -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): { 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): { + pathname: string + params?: Record +} { 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' } } } } diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.ts index b14f0dec783..ea3da7181d8 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.ts @@ -145,3 +145,5 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { refetchPRSidebar: load } } + +export type MobilePrSidebarController = ReturnType diff --git a/mobile/src/source-control/MobileGitHistoryList.tsx b/mobile/src/source-control/MobileGitHistoryList.tsx new file mode 100644 index 00000000000..cfb0eb121ad --- /dev/null +++ b/mobile/src/source-control/MobileGitHistoryList.tsx @@ -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(null) + const [error, setError] = useState(null) + const [expanded, setExpanded] = useState(null) + const [filesById, setFilesById] = useState>({}) + + 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 ( + + [styles.commitHeader, pressed && styles.commitHeaderPressed]} + onPress={() => toggleCommit(item)} + > + {isOpen ? ( + + ) : ( + + )} + + + {item.subject} + + + {item.shortId} · {item.author} · {item.relativeTime} + + + + {isOpen ? ( + + {files === 'loading' || files === undefined ? ( + + ) : files.length === 0 ? ( + No file changes + ) : ( + files.map((file) => ( + + + {file.path} + + + {file.added ? +{file.added} : null} + {file.removed ? -{file.removed} : null} + + + )) + )} + + ) : null} + + ) + }, + [expanded, filesById, toggleCommit] + ) + + if (error) { + return ( + + {error} + + ) + } + if (rows === null) { + return ( + + + + ) + } + if (rows.length === 0) { + return ( + + No commits. + + ) + } + return ( + 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 } +}) diff --git a/mobile/src/source-control/MobileSourceControlBranchCard.tsx b/mobile/src/source-control/MobileSourceControlBranchCard.tsx new file mode 100644 index 00000000000..f7135347fd0 --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlBranchCard.tsx @@ -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 ( + + + + + + {branchLabel} + + + {syncLabel ? {syncLabel} : null} + + + {unstagedCount} changed + {stagedCount} staged + {branchCount > 0 ? {branchCount} on branch : null} + {showConflict ? ( + + {conflictOperation} + {conflictOperation === 'merge' || conflictOperation === 'rebase' ? ( + [styles.abortButton, pressed && styles.abortPressed]} + disabled={conflictBusy} + onPress={() => onAbortConflict(conflictOperation)} + > + + {conflictBusy ? 'Aborting…' : `Abort ${conflictOperation}`} + + + ) : null} + + ) : null} + + {prChip ? : null} + + ) +} diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index 3f1309fad97..a28504a5c53 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -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) { Reconnecting to desktop... ) : null} - - - - - - {branchLabel} - - - {syncLabel ? {syncLabel} : null} - - - {unstagedCount} changed - {stagedCount} staged - {branchEntries.length > 0 ? ( - {branchEntries.length} on branch - ) : null} - {status && status.conflictOperation !== 'unknown' ? ( - - {status.conflictOperation} - {(status.conflictOperation === 'merge' || status.conflictOperation === 'rebase') && ( - [styles.abortButton, pressed && styles.abortPressed]} - disabled={busyAction !== null} - onPress={() => void abortConflictOperation(status.conflictOperation)} - > - - {busyAction === `abort-${status.conflictOperation}` - ? 'Aborting…' - : `Abort ${status.conflictOperation}`} - - - )} - - ) : null} - + {commitFailureRecovery ? ( ) : ( 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(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 = ( )} + + {screenState.kind === 'loading' ? ( @@ -110,7 +170,46 @@ export function MobileSourceControlPanel({ ) : null} ) : ( - + <> + void abortConflictOperation(operation)} + prChip={prChip} + onOpenPr={openPrTab} + /> + {activeTab === 'changes' ? ( + + ) : activeTab === 'pr' ? ( + + ) : ( + + + + )} + )} diff --git a/mobile/src/source-control/MobileSourceControlPrChip.tsx b/mobile/src/source-control/MobileSourceControlPrChip.tsx new file mode 100644 index 00000000000..590fe0b7d1d --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlPrChip.tsx @@ -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 ( + [hubStyles.chip, pressed && hubStyles.chipPressed]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={chipAccessibilityLabel(summary)} + > + + + + {summary.kind === 'loading' ? ( + <> + + + Loading pull request… + + + ) : summary.kind === 'none' ? ( + <> + Create pull request + + + + ) : summary.kind === 'unavailable' ? ( + <> + + {summary.message} + + + + ) : ( + <> + #{summary.number} + + + {summary.stateLabel} + + + + {summary.commentCount && summary.commentCount > 0 ? ( + + + {summary.commentCount} + + ) : null} + + + + )} + + ) +} + +function ChipRollup({ rollup }: { rollup: MobilePrChipRollup }) { + const color = statusColor(rollup.token) + return ( + + + {rollup.text} + + ) +} + +function RollupIcon({ kind, color }: { kind: MobilePrChipRollup['kind']; color: string }) { + const size = 13 + const strokeWidth = 2.3 + switch (kind) { + case 'conflict': + return + case 'failing': + return + case 'running': + return + case 'passed': + return + 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.` + } + } +} diff --git a/mobile/src/source-control/MobileSourceControlSegments.tsx b/mobile/src/source-control/MobileSourceControlSegments.tsx new file mode 100644 index 00000000000..89564aca417 --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlSegments.tsx @@ -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 ( + + {SOURCE_CONTROL_HUB_TABS.map((tab) => { + const isActive = tab === active + return ( + [ + hubStyles.segment, + isActive && hubStyles.segmentActive, + pressed && !isActive && hubStyles.segmentPressed + ]} + onPress={() => onSelect(tab)} + accessibilityRole="tab" + accessibilityState={{ selected: isActive }} + accessibilityLabel={SOURCE_CONTROL_HUB_TAB_LABELS[tab]} + > + + {SOURCE_CONTROL_HUB_TAB_LABELS[tab]} + + + ) + })} + + ) +} diff --git a/mobile/src/source-control/mobile-pr-chip-summary.test.ts b/mobile/src/source-control/mobile-pr-chip-summary.test.ts new file mode 100644 index 00000000000..88186a06ea0 --- /dev/null +++ b/mobile/src/source-control/mobile-pr-chip-summary.test.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/mobile/src/source-control/mobile-pr-chip-summary.ts b/mobile/src/source-control/mobile-pr-chip-summary.ts new file mode 100644 index 00000000000..87f54d43472 --- /dev/null +++ b/mobile/src/source-control/mobile-pr-chip-summary.ts @@ -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() + 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): 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' } +} diff --git a/mobile/src/source-control/mobile-source-control-hub-styles.ts b/mobile/src/source-control/mobile-source-control-hub-styles.ts new file mode 100644 index 00000000000..9aa02cccdd3 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-styles.ts @@ -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 + } +}) diff --git a/mobile/src/source-control/mobile-source-control-hub-tab.test.ts b/mobile/src/source-control/mobile-source-control-hub-tab.test.ts new file mode 100644 index 00000000000..4caf135cf13 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-tab.test.ts @@ -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') + }) +}) diff --git a/mobile/src/source-control/mobile-source-control-hub-tab.ts b/mobile/src/source-control/mobile-source-control-hub-tab.ts new file mode 100644 index 00000000000..e243032a58c --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-tab.ts @@ -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 = { + 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' +} diff --git a/mobile/src/source-control/use-mobile-source-control-runners.ts b/mobile/src/source-control/use-mobile-source-control-runners.ts index 29892344779..d325d3319bb 100644 --- a/mobile/src/source-control/use-mobile-source-control-runners.ts +++ b/mobile/src/source-control/use-mobile-source-control-runners.ts @@ -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( diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts index cc69b89059c..c9a8ca406b0 100644 --- a/mobile/src/source-control/use-mobile-source-control-state.ts +++ b/mobile/src/source-control/use-mobile-source-control-state.ts @@ -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,