diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index ff45db42fa9..ed6b9509a9b 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -15,6 +15,7 @@ import { X, Pin, Bell, + GitBranch, GitPullRequest, SlidersHorizontal, Layers, @@ -1070,6 +1071,20 @@ export default function HostScreen() { actions={ actionTarget ? [ + { + label: 'Source Control', + icon: GitBranch, + onPress: () => { + const params = new URLSearchParams({ + name: actionTarget.displayName || actionTarget.repo, + origin: 'host' + }) + router.push( + `/h/${hostId}/source-control/${encodeURIComponent(actionTarget.worktreeId)}?${params.toString()}` + ) + setActionTarget(null) + } + }, { label: 'Sleep', icon: Moon, diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 49b4fc1a337..d92199758e8 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -24,6 +24,7 @@ import { Folder, File, FileText, + GitBranch, Mic, Monitor, Plus, @@ -2372,6 +2373,19 @@ export default function SessionScreen() { + [styles.filesButton, pressed && styles.filesButtonPressed]} + onPress={() => + router.push({ + pathname: '/h/[hostId]/source-control/[worktreeId]', + params: { hostId, worktreeId, name: worktreeName || '', origin: 'session' } + }) + } + hitSlop={8} + accessibilityLabel="Open source control" + > + + [styles.filesButton, pressed && styles.filesButtonPressed]} onPress={() => @@ -3197,7 +3211,9 @@ const styles = StyleSheet.create({ alignItems: 'center' }, toastText: { - backgroundColor: 'rgba(20, 22, 39, 0.92)', + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, color: colors.textPrimary, fontSize: 13, paddingHorizontal: spacing.lg, diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx new file mode 100644 index 00000000000..7186bc64f9d --- /dev/null +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx @@ -0,0 +1,1443 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + Keyboard, + Platform, + Pressable, + SectionList, + StyleSheet, + Text, + TextInput, + View, + type SectionListRenderItem +} from 'react-native' +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { + ChevronLeft, + ArrowDown, + ArrowDownUp, + ArrowUp, + Check, + CloudUpload, + FileText, + GitBranch, + GitPullRequest, + Minus, + MoreHorizontal, + Plus, + RefreshCw, + Trash2 +} from 'lucide-react-native' +import { useHostClient } from '../../../../src/transport/client-context' +import type { RpcSuccess } from '../../../../src/transport/types' +import { + ActionSheetModal, + type ActionSheetAction +} from '../../../../src/components/ActionSheetModal' +import { ConfirmModal } from '../../../../src/components/ConfirmModal' +import { triggerError, triggerSelection, triggerSuccess } from '../../../../src/platform/haptics' +import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme' +import { + MOBILE_GIT_STATUS_LABELS, + buildMobileSourceControlSections, + countStagedEntries, + countUnstagedEntries, + getStageablePaths, + getUnstageablePaths, + isMobileGitDiscardableEntry, + isMobileGitStageableEntry, + isMobileGitUnavailable, + isMobileGitTransientRefreshError, + type MobileGitFileStatus, + type MobileGitStatusEntry, + type MobileGitStatusResult, + type MobileGitUpstreamStatus, + type MobileSourceControlSection +} from '../../../../src/source-control/mobile-git-status' + +type ScreenState = + | { kind: 'loading' } + | { kind: 'ready'; status: MobileGitStatusResult } + | { kind: 'unavailable'; message: string } + | { kind: 'error'; message: string } + +type LoadStatusOptions = { + preserveReadyOnFailure?: boolean + clearActionErrorOnSuccess?: boolean + force?: boolean +} + +type StatusLoadInFlight = { + key: string + client: unknown + promise: Promise +} + +type GitRequestError = Error & { code?: string } +type GitCommitResult = { success: boolean; error?: string } + +type MobileGitStatusEntryView = MobileGitStatusEntry & { + canDiscard: boolean + canOpen: boolean + canStage: boolean + discardActionId: string + stageActionId: string + unstageActionId: string +} + +const KEYBOARD_COMMIT_BAR_CLEARANCE = 10 +const SELECTOR_RETRY_COUNT = 3 +const SELECTOR_RETRY_DELAY_MS = 250 + +function firstParam(value: string | string[] | undefined): string { + return Array.isArray(value) ? (value[0] ?? '') : (value ?? '') +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function getWorktreeLabel(name: string | undefined, worktreeId: string): string { + if (name?.trim()) { + return name.trim() + } + const pathPart = worktreeId.includes('::') + ? worktreeId.slice(worktreeId.indexOf('::') + 2) + : worktreeId + const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '') + return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree' +} + +function formatBranchLabel(branch: string | undefined, head: string | undefined): string { + if (branch?.startsWith('refs/heads/')) { + return branch.slice('refs/heads/'.length) + } + return branch || head?.slice(0, 7) || 'No branch' +} + +function statusColor(status: MobileGitFileStatus): string { + switch (status) { + case 'added': + case 'copied': + return colors.statusGreen + case 'deleted': + return colors.statusRed + case 'renamed': + return colors.accentBlue + case 'untracked': + return colors.statusAmber + case 'modified': + default: + return colors.textSecondary + } +} + +export default function MobileSourceControlScreen() { + const params = useLocalSearchParams<{ + hostId?: string | string[] + worktreeId?: string | string[] + name?: string | string[] + origin?: string | string[] + }>() + const hostId = firstParam(params.hostId) + const worktreeId = firstParam(params.worktreeId) + const name = firstParam(params.name) + const origin = firstParam(params.origin) + const router = useRouter() + const insets = useSafeAreaInsets() + const { client, state: connState } = useHostClient(hostId) + const [screenState, setScreenState] = useState({ kind: 'loading' }) + const [busyAction, setBusyAction] = useState(null) + const [commitMessage, setCommitMessage] = useState('') + const [discardTarget, setDiscardTarget] = useState(null) + const [showActionSheet, setShowActionSheet] = useState(false) + const [actionError, setActionError] = useState(null) + const [keyboardLift, setKeyboardLift] = useState(0) + const [openingPath, setOpeningPath] = useState(null) + const busyActionRef = useRef(null) + const currentStatusIdentityRef = useRef('') + const loadGenerationRef = useRef(0) + const mountedRef = useRef(true) + const openingPathRef = useRef(null) + const statusLoadInFlightRef = useRef(null) + const worktreeLabel = getWorktreeLabel(name, worktreeId) + const statusIdentityKey = `${hostId}\0${worktreeId}` + currentStatusIdentityRef.current = statusIdentityKey + + useEffect(() => { + return () => { + mountedRef.current = false + loadGenerationRef.current += 1 + } + }, []) + + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' + + const onShow = Keyboard.addListener(showEvent, (event) => { + const height = event.endCoordinates.height - (Platform.OS === 'ios' ? insets.bottom : 0) + setKeyboardLift(Math.max(0, height)) + }) + const onHide = Keyboard.addListener(hideEvent, () => setKeyboardLift(0)) + + return () => { + onShow.remove() + onHide.remove() + } + }, [insets.bottom]) + + const loadStatus = useCallback( + async (options?: LoadStatusOptions) => { + const loadKey = statusIdentityKey + const inFlight = statusLoadInFlightRef.current + if (inFlight && !options?.force && inFlight.key === loadKey && inFlight.client === client) { + return await inFlight.promise + } + + const loadPromise = (async () => { + const generation = loadGenerationRef.current + 1 + loadGenerationRef.current = generation + const isCurrentLoad = () => + mountedRef.current && + loadGenerationRef.current === generation && + currentStatusIdentityRef.current === loadKey + if (!worktreeId) { + if (isCurrentLoad()) { + setScreenState({ kind: 'loading' }) + } + return false + } + if (!client || connState !== 'connected') { + if (isCurrentLoad()) { + setScreenState({ + kind: 'error', + message: + connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...' + }) + } + return false + } + if (!isCurrentLoad()) return false + setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' })) + try { + for (let attempt = 0; attempt <= SELECTOR_RETRY_COUNT; attempt += 1) { + const response = await client.sendRequest('git.status', { + worktree: `id:${worktreeId}` + }) + if (!isCurrentLoad()) return false + if (response.ok) { + const result = (response as RpcSuccess).result as MobileGitStatusResult + setScreenState({ kind: 'ready', status: result }) + if (options?.clearActionErrorOnSuccess !== false) { + setActionError(null) + } + return true + } + if (isMobileGitUnavailable(response.error?.code, response.error?.message)) { + setScreenState({ + kind: 'unavailable', + message: 'Update Orca desktop to use Source Control on mobile.' + }) + return false + } + const shouldRetry = + response.error?.code === 'selector_not_found' || + isMobileGitTransientRefreshError(response.error?.code, response.error?.message) + if (shouldRetry && attempt < SELECTOR_RETRY_COUNT) { + await wait(SELECTOR_RETRY_DELAY_MS) + if (!isCurrentLoad()) return false + continue + } + throw new Error(response.error?.message || 'Unable to load source control') + } + } catch (err) { + if (!isCurrentLoad()) return false + const message = err instanceof Error ? err.message : 'Unable to load source control' + setScreenState((prev) => { + // Why: git mutations can succeed while the immediate status refresh + // races a desktop abort; keep the last good screen instead of flashing + // a full-screen error that Retry fixes a moment later. + if (options?.preserveReadyOnFailure && prev.kind === 'ready') { + return prev + } + return { kind: 'error', message } + }) + return false + } + return false + })() + + statusLoadInFlightRef.current = { key: loadKey, client, promise: loadPromise } + try { + return await loadPromise + } finally { + if (statusLoadInFlightRef.current?.promise === loadPromise) { + statusLoadInFlightRef.current = null + } + } + }, + [client, connState, statusIdentityKey, worktreeId] + ) + + useEffect(() => { + void loadStatus() + }, [loadStatus]) + + const status = screenState.kind === 'ready' ? screenState.status : null + const entries = status?.entries ?? [] + const derivedEntries = useMemo( + () => + entries.map((entry) => ({ + ...entry, + canDiscard: isMobileGitDiscardableEntry(entry), + canOpen: entry.status !== 'deleted' && entry.conflictStatus !== 'unresolved', + canStage: isMobileGitStageableEntry(entry), + discardActionId: `discard:${entry.path}`, + stageActionId: `stage:${entry.path}`, + unstageActionId: `unstage:${entry.path}` + })), + [entries] + ) + const sections = useMemo(() => buildMobileSourceControlSections(derivedEntries), [derivedEntries]) + const stageablePaths = useMemo(() => getStageablePaths(entries), [entries]) + const unstageablePaths = useMemo(() => getUnstageablePaths(entries), [entries]) + const stagedCount = useMemo(() => countStagedEntries(entries), [entries]) + const unstagedCount = useMemo(() => countUnstagedEntries(entries), [entries]) + const branchLabel = formatBranchLabel(status?.branch, status?.head) + const upstream = status?.upstreamStatus + const upstreamKnown = upstream !== undefined + const syncLabel = + upstream && upstream.hasUpstream + ? `${upstream.ahead} ahead, ${upstream.behind} behind` + : upstream && !upstream.hasUpstream + ? 'No upstream' + : null + + const sendGitRequest = useCallback( + async (method: string, params?: Record): Promise => { + if (!client || connState !== 'connected') { + throw new Error('Waiting for desktop...') + } + const response = await client.sendRequest(method, { + worktree: `id:${worktreeId}`, + ...params + }) + if (!response.ok) { + const error = new Error( + response.error?.message || 'Source control action failed' + ) as GitRequestError + error.code = response.error?.code + throw error + } + return (response as RpcSuccess).result as T + }, + [client, connState, worktreeId] + ) + + const sendCommitRequest = useCallback( + async (message: string): Promise => { + const result = await sendGitRequest('git.commit', { message }) + if (!result || result.success !== true) { + throw new Error(result?.error || 'Commit failed') + } + return result + }, + [sendGitRequest] + ) + + const readUpstreamStatusForSync = useCallback(async (): Promise => { + try { + return await sendGitRequest('git.upstreamStatus') + } catch (err) { + const code = err instanceof Error ? (err as GitRequestError).code : undefined + const message = err instanceof Error ? err.message : String(err) + if (!isMobileGitUnavailable(code, message)) { + throw err + } + const status = await sendGitRequest('git.status') + if (!status.upstreamStatus) { + throw new Error('Branch status unavailable') + } + return status.upstreamStatus + } + }, [sendGitRequest]) + + const runGitSyncSteps = useCallback(async () => { + await sendGitRequest('git.fetch') + await sendGitRequest('git.pull') + const nextUpstream = await readUpstreamStatusForSync() + if (nextUpstream.ahead > 0) { + await sendGitRequest('git.push') + } + }, [readUpstreamStatusForSync, sendGitRequest]) + + const runGitWorkflow = useCallback( + async ( + actionId: string, + runner: () => Promise, + options?: { clearCommitMessage?: boolean } + ) => { + if (busyActionRef.current) return false + busyActionRef.current = actionId + setBusyAction(actionId) + setActionError(null) + try { + await runner() + if (!mountedRef.current) return false + if (options?.clearCommitMessage) { + setCommitMessage('') + } + triggerSuccess() + await loadStatus({ preserveReadyOnFailure: true, force: true }) + return true + } catch (err) { + if (!mountedRef.current) return false + triggerError() + setActionError(err instanceof Error ? err.message : 'Source control action failed') + return false + } finally { + if (busyActionRef.current === actionId) { + busyActionRef.current = null + if (mountedRef.current) { + setBusyAction(null) + } + } + } + }, + [loadStatus] + ) + + const runGitAction = useCallback( + async (actionId: string, method: string, params: Record) => { + return await runGitWorkflow(actionId, async () => { + await sendGitRequest(method, params) + }) + }, + [runGitWorkflow, sendGitRequest] + ) + + const runGitSequence = useCallback( + async ( + actionId: string, + steps: Array<{ method: string; params?: Record }>, + options?: { clearCommitMessage?: boolean } + ) => { + return await runGitWorkflow( + actionId, + async () => { + for (const step of steps) { + await sendGitRequest(step.method, step.params) + } + }, + options + ) + }, + [runGitWorkflow, sendGitRequest] + ) + + const runGitSync = useCallback( + async (actionId: string) => await runGitWorkflow(actionId, runGitSyncSteps), + [runGitSyncSteps, runGitWorkflow] + ) + + const stageAll = useCallback(async () => { + const filePaths = stageablePaths + if (filePaths.length === 0) return + await runGitAction('stage-all', 'git.bulkStage', { filePaths }) + }, [runGitAction, stageablePaths]) + + const unstageAll = useCallback(async () => { + const filePaths = unstageablePaths + if (filePaths.length === 0) return + await runGitAction('unstage-all', 'git.bulkUnstage', { filePaths }) + }, [runGitAction, unstageablePaths]) + + const commit = useCallback(async () => { + const message = commitMessage.trim() + if (!message) return false + return await runGitWorkflow( + 'commit', + async () => { + await sendCommitRequest(message) + }, + { clearCommitMessage: true } + ) + }, [commitMessage, runGitWorkflow, sendCommitRequest]) + + const runCommitFollowUps = useCallback( + async (actionId: string, afterCommit: () => Promise) => { + const message = commitMessage.trim() + if (!message) return false + if (busyActionRef.current) return false + busyActionRef.current = actionId + setBusyAction(actionId) + setActionError(null) + let didCommit = false + try { + await sendCommitRequest(message) + didCommit = true + await afterCommit() + if (!mountedRef.current) return false + setCommitMessage('') + triggerSuccess() + await loadStatus({ preserveReadyOnFailure: true, force: true }) + return true + } catch (err) { + if (!mountedRef.current) return false + triggerError() + const message = err instanceof Error ? err.message : 'Source control action failed' + if (didCommit) { + setCommitMessage('') + await loadStatus({ + preserveReadyOnFailure: true, + clearActionErrorOnSuccess: false, + force: true + }) + } + setActionError(message) + return false + } finally { + if (busyActionRef.current === actionId) { + busyActionRef.current = null + if (mountedRef.current) { + setBusyAction(null) + } + } + } + }, + [commitMessage, loadStatus, sendCommitRequest] + ) + + const runCommitSequence = useCallback( + async ( + actionId: string, + afterCommit: Array<{ method: string; params?: Record }> + ) => { + return await runCommitFollowUps(actionId, async () => { + for (const step of afterCommit) { + await sendGitRequest(step.method, step.params) + } + }) + }, + [runCommitFollowUps, sendGitRequest] + ) + + const runCommitSyncSequence = useCallback(async () => { + return await runCommitFollowUps('commit-sync', runGitSyncSteps) + }, [runCommitFollowUps, runGitSyncSteps]) + + const runActionSheetCommit = useCallback(async () => { + await commit() + setShowActionSheet(false) + }, [commit]) + + const runActionSheetCommitSequence = useCallback( + async ( + actionId: string, + afterCommit: Array<{ method: string; params?: Record }> + ) => { + await runCommitSequence(actionId, afterCommit) + setShowActionSheet(false) + }, + [runCommitSequence] + ) + + const runActionSheetCommitSync = useCallback(async () => { + await runCommitSyncSequence() + setShowActionSheet(false) + }, [runCommitSyncSequence]) + + const runActionSheetGitSequence = useCallback( + async ( + actionId: string, + steps: Array<{ method: string; params?: Record }> + ) => { + await runGitSequence(actionId, steps) + setShowActionSheet(false) + }, + [runGitSequence] + ) + + const runActionSheetGitSync = useCallback(async () => { + await runGitSync('sync') + setShowActionSheet(false) + }, [runGitSync]) + + const openFile = useCallback( + async (entry: MobileGitStatusEntry) => { + if (entry.status === 'deleted' || entry.conflictStatus === 'unresolved') return + if (openingPathRef.current || busyActionRef.current) return + if (!client || connState !== 'connected') { + if (!mountedRef.current) return + setActionError('Waiting for desktop...') + return + } + openingPathRef.current = entry.path + setOpeningPath(entry.path) + try { + setActionError(null) + const response = await client.sendRequest('files.open', { + worktree: `id:${worktreeId}`, + relativePath: entry.path + }) + if (!response.ok) { + throw new Error(response.error?.message || 'Unable to open file') + } + if (!mountedRef.current) return + triggerSelection() + if (origin === 'session') { + router.back() + return + } + const params = new URLSearchParams() + if (name) { + params.set('name', name) + } + const query = params.toString() + router.replace( + `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}${query ? `?${query}` : ''}` + ) + } catch (err) { + if (!mountedRef.current) return + triggerError() + setActionError(err instanceof Error ? err.message : 'Unable to open file') + } finally { + if (openingPathRef.current === entry.path) { + openingPathRef.current = null + if (mountedRef.current) { + setOpeningPath(null) + } + } + } + }, + [client, connState, hostId, name, origin, router, worktreeId] + ) + + const actionSheetActions = useMemo(() => { + const hasMessage = commitMessage.trim().length > 0 + const hasStaged = stagedCount > 0 + const hasUpstream = upstream?.hasUpstream === true + const ahead = upstream?.ahead ?? 0 + const behind = upstream?.behind ?? 0 + const busy = busyAction !== null + const commitHint = !hasStaged + ? 'Stage at least one file' + : !hasMessage + ? 'Enter a commit message' + : undefined + const remoteHint = !upstreamKnown + ? 'Checking branch status...' + : hasUpstream + ? undefined + : 'Publish Branch first' + const createPrHint = 'Pull requests are not available on mobile yet' + + return [ + { + label: 'Commit', + icon: Check, + disabled: busy || !!commitHint, + hint: commitHint, + loading: busyAction === 'commit', + skipAutoClose: true, + onPress: () => void runActionSheetCommit() + }, + { + label: 'Commit & Push', + icon: ArrowUp, + disabled: busy || !!commitHint || !upstreamKnown || !hasUpstream, + hint: commitHint ?? remoteHint, + loading: busyAction === 'commit-push', + skipAutoClose: true, + onPress: () => void runActionSheetCommitSequence('commit-push', [{ method: 'git.push' }]) + }, + { + label: 'Commit & Sync', + icon: ArrowDownUp, + disabled: busy || !!commitHint || !upstreamKnown || !hasUpstream || behind === 0, + hint: + commitHint ?? + (!upstreamKnown || !hasUpstream + ? remoteHint + : behind === 0 + ? 'Nothing to pull' + : undefined), + loading: busyAction === 'commit-sync', + skipAutoClose: true, + onPress: () => void runActionSheetCommitSync() + }, + { + label: ahead > 0 ? `Push (${ahead})` : 'Push', + icon: ArrowUp, + disabled: busy || !upstreamKnown || !hasUpstream || ahead === 0, + hint: !hasUpstream ? remoteHint : ahead === 0 ? 'Nothing to push' : undefined, + loading: busyAction === 'push', + skipAutoClose: true, + onPress: () => void runActionSheetGitSequence('push', [{ method: 'git.push' }]) + }, + { + label: 'Create PR', + icon: GitPullRequest, + disabled: true, + hint: createPrHint, + onPress: () => {} + }, + { + label: 'Push & Create PR', + icon: GitPullRequest, + disabled: true, + hint: createPrHint, + onPress: () => {} + }, + { + label: behind > 0 ? `Pull (${behind})` : 'Pull', + icon: ArrowDown, + disabled: busy || !upstreamKnown || !hasUpstream || behind === 0, + hint: !hasUpstream ? remoteHint : behind === 0 ? 'Nothing to pull' : undefined, + loading: busyAction === 'pull', + skipAutoClose: true, + onPress: () => void runActionSheetGitSequence('pull', [{ method: 'git.pull' }]) + }, + { + label: ahead > 0 || behind > 0 ? `Sync (↓${behind} ↑${ahead})` : 'Sync', + icon: ArrowDownUp, + disabled: busy || !upstreamKnown || !hasUpstream || (ahead === 0 && behind === 0), + hint: + !upstreamKnown || !hasUpstream + ? remoteHint + : ahead === 0 && behind === 0 + ? 'Branch is up to date' + : undefined, + loading: busyAction === 'sync', + skipAutoClose: true, + onPress: () => void runActionSheetGitSync() + }, + { + label: 'Fetch', + icon: RefreshCw, + disabled: busy, + loading: busyAction === 'fetch', + skipAutoClose: true, + onPress: () => void runActionSheetGitSequence('fetch', [{ method: 'git.fetch' }]) + }, + { + label: 'Publish Branch', + icon: CloudUpload, + disabled: busy || !upstreamKnown || hasUpstream, + hint: !upstreamKnown + ? 'Checking branch status...' + : hasUpstream + ? 'Branch is already published' + : undefined, + loading: busyAction === 'publish', + skipAutoClose: true, + onPress: () => + void runActionSheetGitSequence('publish', [ + { method: 'git.push', params: { publish: true } } + ]) + } + ] + }, [ + busyAction, + commitMessage, + runActionSheetCommit, + runActionSheetCommitSequence, + runActionSheetCommitSync, + runActionSheetGitSequence, + runActionSheetGitSync, + stagedCount, + upstream, + upstreamKnown + ]) + + const renderItem = useCallback< + SectionListRenderItem< + MobileGitStatusEntryView, + MobileSourceControlSection + > + >( + ({ item }) => { + const rowBusy = + busyAction === item.stageActionId || + busyAction === item.unstageActionId || + busyAction === item.discardActionId || + openingPath === item.path + const rowDisabled = !item.canOpen || busyAction !== null || openingPath !== null + return ( + [ + styles.fileRow, + pressed && item.canOpen && styles.fileRowPressed, + rowDisabled && styles.fileRowDisabled, + !item.canOpen && styles.fileRowUnavailable + ]} + onPress={() => void openFile(item)} + disabled={rowDisabled} + accessibilityLabel={`Open changed file ${item.path}`} + > + + + {MOBILE_GIT_STATUS_LABELS[item.status]} + + + + + + {item.path} + + {item.oldPath ? ( + + from {item.oldPath} + + ) : item.conflictStatus === 'unresolved' ? ( + + Unresolved conflict + + ) : null} + + {rowBusy ? ( + + ) : item.area === 'staged' ? ( + [ + styles.iconButton, + (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled, + pressed && styles.iconButtonPressed + ]} + disabled={busyAction !== null || openingPath !== null} + onPress={() => + void runGitAction(item.unstageActionId, 'git.unstage', { filePath: item.path }) + } + hitSlop={8} + accessibilityLabel={`Unstage ${item.path}`} + > + + + ) : item.canStage || item.canDiscard ? ( + + {item.canStage ? ( + [ + styles.iconButton, + (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled, + pressed && styles.iconButtonPressed + ]} + disabled={busyAction !== null || openingPath !== null} + onPress={() => + void runGitAction(item.stageActionId, 'git.stage', { filePath: item.path }) + } + hitSlop={8} + accessibilityLabel={`Stage ${item.path}`} + > + + + ) : null} + {item.canDiscard ? ( + [ + styles.iconButton, + (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled, + pressed && styles.iconButtonPressed + ]} + disabled={busyAction !== null || openingPath !== null} + onPress={() => setDiscardTarget(item)} + hitSlop={8} + accessibilityLabel={`Discard ${item.path}`} + > + + + ) : null} + + ) : null} + + ) + }, + [busyAction, openFile, openingPath, runGitAction] + ) + + const keyExtractor = useCallback( + (item: MobileGitStatusEntryView) => `${item.area}:${item.path}:${item.oldPath ?? ''}`, + [] + ) + + const renderSectionHeader = useCallback( + ({ section }: { section: MobileSourceControlSection }) => ( + + {section.title} + {section.data.length} + + ), + [] + ) + + return ( + + + + [styles.backButton, pressed && styles.backButtonPressed]} + onPress={() => router.back()} + hitSlop={8} + accessibilityLabel="Back to session" + > + + + + + Source Control + + + {worktreeLabel} + + + [ + styles.refreshButton, + (busyAction !== null || openingPath !== null) && styles.refreshButtonDisabled, + pressed && styles.refreshButtonPressed + ]} + onPress={() => void loadStatus()} + disabled={busyAction !== null || openingPath !== null} + hitSlop={8} + accessibilityLabel="Refresh source control" + > + + + + + + {screenState.kind === 'loading' ? ( + + + + ) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? ( + + + {screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'} + + {screenState.message} + {screenState.kind === 'error' ? ( + void loadStatus()}> + Retry + + ) : null} + + ) : ( + <> + + + + + + {branchLabel} + + + {syncLabel ? {syncLabel} : null} + + + {unstagedCount} changed + {stagedCount} staged + {status && status.conflictOperation !== 'unknown' ? ( + {status.conflictOperation} + ) : null} + + {actionError ? ( + + + {actionError} + + + ) : null} + + [ + styles.bulkButton, + (stageablePaths.length === 0 || busyAction !== null || openingPath !== null) && + styles.bulkButtonDisabled, + pressed && styles.bulkButtonPressed + ]} + onPress={() => void stageAll()} + disabled={ + busyAction !== null || openingPath !== null || stageablePaths.length === 0 + } + > + {busyAction === 'stage-all' ? ( + + ) : ( + + )} + Stage All + + [ + styles.bulkButton, + (unstageablePaths.length === 0 || busyAction !== null || openingPath !== null) && + styles.bulkButtonDisabled, + pressed && styles.bulkButtonPressed + ]} + onPress={() => void unstageAll()} + disabled={ + busyAction !== null || openingPath !== null || unstageablePaths.length === 0 + } + > + {busyAction === 'unstage-all' ? ( + + ) : ( + + )} + Unstage All + + [ + styles.bulkMenuButton, + pressed && styles.bulkButtonPressed, + (busyAction !== null || openingPath !== null) && styles.bulkButtonDisabled + ]} + onPress={() => setShowActionSheet(true)} + disabled={busyAction !== null || openingPath !== null} + hitSlop={8} + accessibilityLabel="Open source control actions" + > + + + + + + {entries.length === 0 ? ( + + No Changes + Working tree is clean. + + ) : ( + + )} + + 0 ? keyboardLift + KEYBOARD_COMMIT_BAR_CLEARANCE : keyboardLift, + paddingBottom: keyboardLift > 0 ? spacing.md : spacing.md + insets.bottom + } + ]} + > + + {stagedCount === 0 ? ( + + No staged files + + ) : ( + void commit()} + /> + )} + [ + styles.commitButton, + (!commitMessage.trim() || + stagedCount === 0 || + busyAction !== null || + openingPath !== null) && + styles.commitButtonDisabled, + pressed && styles.commitButtonPressed + ]} + onPress={() => void commit()} + disabled={ + !commitMessage.trim() || + stagedCount === 0 || + busyAction !== null || + openingPath !== null + } + > + {busyAction === 'commit' ? ( + + ) : ( + Commit + )} + + + + + )} + + setShowActionSheet(false)} + /> + + { + if (discardTarget) { + void runGitAction(`discard:${discardTarget.path}`, 'git.discard', { + filePath: discardTarget.path + }) + } + }} + onCancel={() => setDiscardTarget(null)} + /> + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + header: { + backgroundColor: colors.bgPanel, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + topBar: { + minHeight: 58, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.sm + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.xs + }, + backButtonPressed: { + backgroundColor: colors.bgRaised + }, + titleBlock: { + flex: 1, + minWidth: 0 + }, + title: { + color: colors.textPrimary, + fontSize: 16, + fontWeight: '700' + }, + meta: { + color: colors.textSecondary, + fontSize: typography.metaSize, + marginTop: 2 + }, + refreshButton: { + width: 36, + height: 36, + borderRadius: radii.button, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.xs + }, + refreshButtonPressed: { + backgroundColor: colors.bgRaised + }, + refreshButtonDisabled: { + opacity: 0.45 + }, + summaryCard: { + margin: spacing.lg, + marginBottom: spacing.sm, + padding: spacing.md, + borderRadius: radii.card, + backgroundColor: colors.bgPanel, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + summaryHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.md + }, + branchLine: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + branchText: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + syncText: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + countRow: { + flexDirection: 'row', + gap: spacing.md, + marginTop: spacing.sm + }, + countText: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + conflictText: { + color: colors.statusAmber, + fontSize: typography.metaSize, + textTransform: 'capitalize' + }, + actionError: { + marginTop: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.statusRed + }, + actionErrorText: { + color: colors.textPrimary, + fontSize: typography.metaSize, + lineHeight: 16 + }, + bulkRow: { + flexDirection: 'row', + gap: spacing.sm, + marginTop: spacing.md + }, + bulkButton: { + flex: 1, + minHeight: 36, + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: spacing.xs + }, + bulkMenuButton: { + width: 42, + minHeight: 36, + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + bulkButtonDisabled: { + opacity: 0.45 + }, + bulkButtonPressed: { + opacity: 0.75 + }, + bulkButtonText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + listContent: { + paddingHorizontal: spacing.lg, + paddingBottom: 136 + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingTop: spacing.md, + paddingBottom: spacing.xs + }, + sectionTitle: { + color: colors.textSecondary, + fontSize: 11, + fontWeight: '700', + textTransform: 'uppercase' + }, + sectionCount: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + }, + fileRow: { + minHeight: 50, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + fileRowPressed: { + backgroundColor: colors.bgPanel + }, + fileRowDisabled: { + opacity: 0.78 + }, + fileRowUnavailable: { + opacity: 0.72 + }, + statusBadge: { + width: 24, + alignItems: 'center' + }, + statusBadgeText: { + fontFamily: typography.monoFamily, + fontSize: typography.metaSize, + fontWeight: '700' + }, + fileTextBlock: { + flex: 1, + minWidth: 0 + }, + filePath: { + color: colors.textPrimary, + fontSize: typography.bodySize + }, + filePathDisabled: { + color: colors.textSecondary + }, + fileMeta: { + color: colors.textMuted, + fontSize: typography.metaSize, + marginTop: 2 + }, + rowActions: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + iconButton: { + width: 32, + height: 32, + borderRadius: radii.button, + alignItems: 'center', + justifyContent: 'center' + }, + iconButtonPressed: { + backgroundColor: colors.bgRaised + }, + iconButtonDisabled: { + opacity: 0.45 + }, + commitBar: { + position: 'absolute', + left: 0, + right: 0, + gap: spacing.xs, + padding: spacing.lg, + paddingTop: spacing.md, + backgroundColor: colors.bgPanel, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + commitRow: { + flexDirection: 'row', + gap: spacing.sm + }, + commitInput: { + flex: 1, + minHeight: 42, + borderRadius: radii.input, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgBase, + color: colors.textPrimary, + paddingHorizontal: spacing.md, + fontSize: typography.bodySize + }, + commitInputDisabled: { + backgroundColor: colors.bgPanel, + borderColor: colors.borderSubtle, + borderStyle: 'dashed', + alignItems: 'center', + justifyContent: 'center' + }, + commitInputDisabledText: { + color: colors.textMuted, + fontSize: typography.bodySize, + fontWeight: '600' + }, + commitButton: { + minWidth: 88, + minHeight: 42, + borderRadius: radii.button, + backgroundColor: colors.textPrimary, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.md + }, + commitButtonDisabled: { + opacity: 0.45 + }, + commitButtonPressed: { + opacity: 0.75 + }, + commitButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '700' + }, + state: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl + }, + stateTitle: { + color: colors.textPrimary, + fontSize: 16, + fontWeight: '700', + marginBottom: spacing.xs + }, + stateText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + lineHeight: 20, + textAlign: 'center' + }, + 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' + } +}) diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx index 1bd3fec4185..c4071605cd2 100644 --- a/mobile/app/h/_layout.tsx +++ b/mobile/app/h/_layout.tsx @@ -12,6 +12,10 @@ export default function HostGroupLayout() { + ) } diff --git a/mobile/scripts/mock-server.ts b/mobile/scripts/mock-server.ts index 14c7ab2c3db..64660ff4dc1 100644 --- a/mobile/scripts/mock-server.ts +++ b/mobile/scripts/mock-server.ts @@ -4,6 +4,7 @@ // runtime exposes, with realistic fake data. Supports E2EE handshake. import { WebSocketServer, type WebSocket } from 'ws' import nacl from 'tweetnacl' +import type { MobileGitStatusEntry } from '../src/source-control/mobile-git-status' const PORT = Number(process.env.PORT) || 6768 const AUTH_TOKEN = 'mock-device-token' @@ -111,6 +112,19 @@ const STREAMING_CHUNKS = [ '\nUpdating src/auth/middleware.ts...\n' ] +type FakeGitEntry = MobileGitStatusEntry & { + stagedFromUntracked?: boolean +} + +let fakeGitEntries: FakeGitEntry[] = [ + { path: 'src/auth/middleware.ts', status: 'modified', area: 'unstaged' }, + { path: 'src/auth/jwt.ts', status: 'untracked', area: 'untracked' }, + { path: 'README.md', status: 'modified', area: 'staged' } +] +let fakeAhead = 1 +let fakeBehind = 0 +let fakeHasUpstream = true + type RpcRequest = { id: string method: string @@ -127,6 +141,27 @@ type RpcResponse = { _meta: { runtimeId: string } } +function toGitStatusEntry(entry: FakeGitEntry): MobileGitStatusEntry { + const { stagedFromUntracked: _stagedFromUntracked, ...statusEntry } = entry + return statusEntry +} + +function stageFakeGitEntry(entry: FakeGitEntry, filePaths: Set): FakeGitEntry { + if (!filePaths.has(entry.path)) return entry + if (entry.area === 'untracked') { + return { ...entry, area: 'staged', status: 'added', stagedFromUntracked: true } + } + return { ...entry, area: 'staged' } +} + +function unstageFakeGitEntry(entry: FakeGitEntry, filePaths: Set): FakeGitEntry { + if (!filePaths.has(entry.path)) return entry + if (entry.stagedFromUntracked) { + return { ...entry, area: 'untracked', status: 'untracked', stagedFromUntracked: false } + } + return { ...entry, area: 'unstaged' } +} + function success(id: string, result: unknown, streaming?: boolean): RpcResponse { const resp: RpcResponse = { id, ok: true, result, _meta: { runtimeId: 'mock-runtime' } } if (streaming) { @@ -203,6 +238,91 @@ function handleRequest( send(success(request.id, { unsubscribed: true })) break + case 'git.status': + send( + success(request.id, { + entries: fakeGitEntries.map(toGitStatusEntry), + conflictOperation: 'unknown', + branch: 'refs/heads/feature/auth-refactor', + upstreamStatus: { + hasUpstream: fakeHasUpstream, + upstreamName: 'origin/feature/auth-refactor', + ahead: fakeAhead, + behind: fakeBehind + } + }) + ) + break + + case 'git.upstreamStatus': + send( + success(request.id, { + hasUpstream: fakeHasUpstream, + upstreamName: 'origin/feature/auth-refactor', + ahead: fakeAhead, + behind: fakeBehind + }) + ) + break + + case 'git.stage': { + const filePath = String(request.params?.filePath ?? '') + fakeGitEntries = fakeGitEntries.map((entry) => stageFakeGitEntry(entry, new Set([filePath]))) + send(success(request.id, { ok: true })) + break + } + + case 'git.bulkStage': { + const filePaths = new Set((request.params?.filePaths as string[] | undefined) ?? []) + fakeGitEntries = fakeGitEntries.map((entry) => stageFakeGitEntry(entry, filePaths)) + send(success(request.id, { ok: true })) + break + } + + case 'git.unstage': { + const filePath = String(request.params?.filePath ?? '') + fakeGitEntries = fakeGitEntries.map((entry) => + unstageFakeGitEntry(entry, new Set([filePath])) + ) + send(success(request.id, { ok: true })) + break + } + + case 'git.bulkUnstage': { + const filePaths = new Set((request.params?.filePaths as string[] | undefined) ?? []) + fakeGitEntries = fakeGitEntries.map((entry) => unstageFakeGitEntry(entry, filePaths)) + send(success(request.id, { ok: true })) + break + } + + case 'git.discard': { + const filePath = String(request.params?.filePath ?? '') + fakeGitEntries = fakeGitEntries.filter((entry) => entry.path !== filePath) + send(success(request.id, { ok: true })) + break + } + + case 'git.commit': + fakeGitEntries = fakeGitEntries.filter((entry) => entry.area !== 'staged') + fakeAhead += 1 + send(success(request.id, { success: true })) + break + + case 'git.fetch': + send(success(request.id, { ok: true })) + break + + case 'git.pull': + fakeBehind = 0 + send(success(request.id, { ok: true })) + break + + case 'git.push': + fakeHasUpstream = true + fakeAhead = 0 + send(success(request.id, { ok: true })) + break + default: send(error(request.id, 'method_not_found', `Unknown method: ${request.method}`)) } diff --git a/mobile/src/components/ActionSheetModal.tsx b/mobile/src/components/ActionSheetModal.tsx index a68b08ea637..f7483c8d1d5 100644 --- a/mobile/src/components/ActionSheetModal.tsx +++ b/mobile/src/components/ActionSheetModal.tsx @@ -1,4 +1,4 @@ -import { View, Text, Pressable, StyleSheet } from 'react-native' +import { ActivityIndicator, View, Text, Pressable, StyleSheet } from 'react-native' import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native' import { colors, spacing, typography } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' @@ -7,6 +7,9 @@ export type ActionSheetAction = { label: string icon?: LucideIcon destructive?: boolean + disabled?: boolean + hint?: string + loading?: boolean skipAutoClose?: boolean onPress: () => void } @@ -53,7 +56,12 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content {i > 0 && } [styles.action, pressed && styles.actionPressed]} + style={({ pressed }) => [ + styles.action, + action.disabled && styles.actionDisabled, + pressed && !action.disabled && !action.loading && styles.actionPressed + ]} + disabled={action.disabled || action.loading} onPress={() => { action.onPress() if (!action.skipAutoClose && onClose) { @@ -65,11 +73,21 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content size={16} color={action.destructive ? colors.statusRed : colors.textSecondary} /> - - {action.label} - + + + {action.label} + + {action.hint ? {action.hint} : null} + + {action.loading ? ( + + ) : null} ) @@ -81,7 +99,7 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) { return ( - + ) @@ -119,15 +137,30 @@ const styles = StyleSheet.create({ paddingVertical: spacing.md, paddingHorizontal: spacing.md + 2 }, + actionDisabled: { + opacity: 0.58 + }, actionPressed: { backgroundColor: colors.bgRaised }, + actionTextBlock: { + flex: 1, + minWidth: 0 + }, actionText: { fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary }, + actionTextDisabled: { + color: colors.textSecondary + }, actionTextDestructive: { color: colors.statusRed + }, + actionHint: { + marginTop: 2, + fontSize: typography.metaSize, + color: colors.textMuted } }) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 2698353fb92..4d98f2a92e2 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -14,6 +14,7 @@ import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-g import Animated, { useSharedValue, useAnimatedStyle, + useAnimatedScrollHandler, withSpring, withTiming, runOnJS, @@ -30,20 +31,17 @@ const SPRING_CONFIG = { damping: 28, stiffness: 400 } const RUBBER_BAND_FACTOR = 0.25 const SHOW_DURATION = 180 const HIDE_DURATION = 150 +const TOP_SCROLL_EPSILON = 1 type Props = { visible: boolean onClose: () => void children: ReactNode + dragContentToDismiss?: boolean } -export function BottomDrawer({ visible, onClose, children }: Props) { +export function BottomDrawer({ visible, onClose, children, dragContentToDismiss = false }: Props) { const [mounted, setMounted] = useState(visible) - const translateY = useSharedValue(0) - const progress = useSharedValue(0) - const keyboardOffset = useSharedValue(0) - const { height: screenHeight } = useWindowDimensions() - const insets = useSafeAreaInsets() useEffect(() => { if (visible) { @@ -51,21 +49,56 @@ export function BottomDrawer({ visible, onClose, children }: Props) { } }, [visible]) - useEffect(() => { - if (!mounted) return + // Why: hidden drawers are rendered by parent screens even while closed; keep + // their Reanimated/Gesture setup out of hot paths like commit-message typing. + if (!mounted) return null + return ( + setMounted(false)} + dragContentToDismiss={dragContentToDismiss} + > + {children} + + ) +} + +type MountedBottomDrawerProps = Props & { + onHidden: () => void +} + +function MountedBottomDrawer({ + visible, + onClose, + onHidden, + children, + dragContentToDismiss = false +}: MountedBottomDrawerProps) { + const translateY = useSharedValue(0) + const progress = useSharedValue(0) + const keyboardOffset = useSharedValue(0) + const scrollOffsetY = useSharedValue(0) + const contentDragStartY = useSharedValue(0) + const contentDragCanDismiss = useSharedValue(false) + const { height: screenHeight } = useWindowDimensions() + const insets = useSafeAreaInsets() + + useEffect(() => { if (visible) { translateY.value = 0 + scrollOffsetY.value = 0 progress.value = withTiming(1, { duration: SHOW_DURATION }) } else { Keyboard.dismiss() progress.value = withTiming(0, { duration: HIDE_DURATION }, (finished) => { if (finished) { - runOnJS(setMounted)(false) + runOnJS(onHidden)() } }) } - }, [mounted, visible]) + }, [onHidden, visible]) // Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable // inside Modal (iOS ignores KAV; Android needs adjustNothing for @@ -106,7 +139,14 @@ export function BottomDrawer({ visible, onClose, children }: Props) { onClose() }, [onClose]) - const panGesture = Gesture.Pan() + const scrollHandler = useAnimatedScrollHandler((event) => { + scrollOffsetY.value = Math.max(event.contentOffset.y, 0) + }) + + const scrollGesture = Gesture.Native() + const handlePanGesture = Gesture.Pan() + .activeOffsetY([-8, 8]) + .simultaneousWithExternalGesture(scrollGesture) .onUpdate((e) => { if (e.translationY > 0) { translateY.value = e.translationY @@ -127,6 +167,53 @@ export function BottomDrawer({ visible, onClose, children }: Props) { translateY.value = withSpring(0, SPRING_CONFIG) } }) + const contentPanGesture = Gesture.Pan() + .activeOffsetY([-8, 8]) + .simultaneousWithExternalGesture(scrollGesture) + .onBegin(() => { + contentDragStartY.value = 0 + contentDragCanDismiss.value = scrollOffsetY.value <= TOP_SCROLL_EPSILON + }) + .onUpdate((e) => { + // Why: action-sheet content can be taller than the drawer; downward drags + // should scroll back to the top before they start dismissing the sheet. + if (scrollOffsetY.value > TOP_SCROLL_EPSILON) { + contentDragCanDismiss.value = false + contentDragStartY.value = 0 + if (translateY.value !== 0) { + translateY.value = withSpring(0, SPRING_CONFIG) + } + return + } + + if (!contentDragCanDismiss.value) { + contentDragCanDismiss.value = true + contentDragStartY.value = e.translationY + } + + const translationY = e.translationY - contentDragStartY.value + if (translationY > 0) { + translateY.value = translationY + } else { + translateY.value = translationY * RUBBER_BAND_FACTOR + } + }) + .onEnd((e) => { + if (!contentDragCanDismiss.value || scrollOffsetY.value > TOP_SCROLL_EPSILON) return + + const translationY = e.translationY - contentDragStartY.value + if (translationY > DISMISS_THRESHOLD || e.velocityY > 500) { + const velocity = Math.max(e.velocityY, 800) + const remaining = screenHeight - translationY + const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300) + translateY.value = withTiming(screenHeight, { duration }) + progress.value = withTiming(0, { duration }, () => { + runOnJS(dismiss)() + }) + } else { + translateY.value = withSpring(0, SPRING_CONFIG) + } + }) const drawerStyle = useAnimatedStyle(() => ({ transform: [ @@ -151,10 +238,6 @@ export function BottomDrawer({ visible, onClose, children }: Props) { }) as { pointerEvents: 'auto' | 'none' } ) - // Why: hidden drawers can contain auto-focused inputs; keeping them mounted - // lets Android open the keyboard even when the drawer is offscreen. - if (!mounted) return null - return ( @@ -173,22 +256,53 @@ export function BottomDrawer({ visible, onClose, children }: Props) { drawerStyle ]} > - - - - - - - {children} - + {dragContentToDismiss ? ( + <> + + + + + + + + + + {children} + + + + + + ) : ( + <> + + + + + + + {children} + + + )} diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts new file mode 100644 index 00000000000..9c3575a75b2 --- /dev/null +++ b/mobile/src/source-control/mobile-git-status.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import type { GitStatusResult } from '../../../src/shared/git-status-types' +import { + buildMobileSourceControlSections, + countStagedEntries, + countUnstagedEntries, + getStageablePaths, + getUnstageablePaths, + isMobileGitDiscardableEntry, + isMobileGitStageableEntry, + isMobileGitTransientRefreshError, + isMobileGitUnavailable, + type MobileGitStatusEntry, + type MobileGitStatusResult +} from './mobile-git-status' + +const entries: MobileGitStatusEntry[] = [ + { path: 'b.ts', status: 'modified', area: 'staged' }, + { path: 'a.ts', status: 'modified', area: 'unstaged' }, + { path: 'new.ts', status: 'untracked', area: 'untracked' } +] + +describe('mobile source control status helpers', () => { + it('keeps the mobile RPC status type in lockstep with the shared git contract', () => { + expectTypeOf().toEqualTypeOf() + }) + + it('builds sections in the mobile source control order', () => { + const sections = buildMobileSourceControlSections(entries) + + expect(sections.map((section) => section.title)).toEqual([ + 'Changes', + 'Untracked Files', + 'Staged Changes' + ]) + }) + + it('computes actionable path sets', () => { + expect(countUnstagedEntries(entries)).toBe(2) + expect(countStagedEntries(entries)).toBe(1) + expect(getStageablePaths(entries)).toEqual(['a.ts', 'new.ts']) + expect(getUnstageablePaths(entries)).toEqual(['b.ts']) + }) + + it('keeps unresolved conflicts out of stage actions', () => { + const conflictedEntries: MobileGitStatusEntry[] = [ + { path: 'ready.ts', status: 'modified', area: 'unstaged' }, + { + path: 'conflicted.ts', + status: 'modified', + area: 'unstaged', + conflictStatus: 'unresolved' + }, + { + path: 'resolved.ts', + status: 'modified', + area: 'unstaged', + conflictStatus: 'resolved_locally' + } + ] + + expect(getStageablePaths(conflictedEntries)).toEqual(['ready.ts', 'resolved.ts']) + expect(isMobileGitStageableEntry(conflictedEntries[1])).toBe(false) + expect(isMobileGitDiscardableEntry(conflictedEntries[1])).toBe(false) + expect(isMobileGitDiscardableEntry(conflictedEntries[2])).toBe(false) + }) + + it('sorts entries by desktop-compatible conflict rank, then path', () => { + const sections = buildMobileSourceControlSections([ + { path: 'zeta.ts', status: 'modified', area: 'unstaged' }, + { + path: 'beta.ts', + status: 'modified', + area: 'unstaged', + conflictStatus: 'resolved_locally' + }, + { + path: 'alpha.ts', + status: 'modified', + area: 'unstaged', + conflictStatus: 'unresolved' + }, + { path: 'aardvark.ts', status: 'added', area: 'unstaged' } + ]) + + expect(sections[0].data.map((entry) => entry.path)).toEqual([ + 'alpha.ts', + 'beta.ts', + 'aardvark.ts', + 'zeta.ts' + ]) + }) + + it('recognizes old-desktop unavailable responses', () => { + expect(isMobileGitUnavailable('forbidden', 'Method is not available to mobile clients')).toBe( + true + ) + expect(isMobileGitUnavailable('method_not_found', 'Unknown method')).toBe(true) + expect(isMobileGitUnavailable('bad_request', 'Missing worktree selector')).toBe(false) + }) + + it('recognizes transient status refresh aborts', () => { + expect(isMobileGitTransientRefreshError('runtime_error', 'Aborting')).toBe(true) + expect(isMobileGitTransientRefreshError('request_aborted', 'request_aborted')).toBe(true) + expect(isMobileGitTransientRefreshError('runtime_error', 'fatal: not a git repository')).toBe( + false + ) + }) +}) diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts new file mode 100644 index 00000000000..9c78b0f9d0b --- /dev/null +++ b/mobile/src/source-control/mobile-git-status.ts @@ -0,0 +1,103 @@ +import type { + GitFileStatus, + GitStagingArea, + GitStatusEntry, + GitStatusResult, + GitUpstreamStatus +} from '../../../src/shared/git-status-types' + +export type MobileGitFileStatus = GitFileStatus +export type MobileGitStagingArea = GitStagingArea +export type MobileGitStatusEntry = GitStatusEntry +export type MobileGitUpstreamStatus = GitUpstreamStatus +export type MobileGitStatusResult = GitStatusResult + +export type MobileSourceControlSection = + { + area: MobileGitStagingArea + title: string + data: TEntry[] + } + +const AREA_ORDER: MobileGitStagingArea[] = ['unstaged', 'untracked', 'staged'] + +const AREA_TITLES: Record = { + unstaged: 'Changes', + untracked: 'Untracked Files', + staged: 'Staged Changes' +} + +export const MOBILE_GIT_STATUS_LABELS: Record = { + modified: 'M', + added: 'A', + deleted: 'D', + renamed: 'R', + untracked: 'U', + copied: 'C' +} + +function compareGitStatusEntries(a: MobileGitStatusEntry, b: MobileGitStatusEntry): number { + return ( + getConflictSortRank(a) - getConflictSortRank(b) || + a.path.localeCompare(b.path, undefined, { numeric: true }) + ) +} + +function getConflictSortRank(entry: MobileGitStatusEntry): number { + if (entry.conflictStatus === 'unresolved') return 0 + if (entry.conflictStatus === 'resolved_locally') return 1 + return 2 +} + +export function buildMobileSourceControlSections( + entries: readonly TEntry[] +): MobileSourceControlSection[] { + return AREA_ORDER.map((area) => ({ + area, + title: AREA_TITLES[area], + data: entries.filter((entry) => entry.area === area).sort(compareGitStatusEntries) + })).filter((section) => section.data.length > 0) +} + +export function countStagedEntries(entries: readonly MobileGitStatusEntry[]): number { + return entries.filter((entry) => entry.area === 'staged').length +} + +export function countUnstagedEntries(entries: readonly MobileGitStatusEntry[]): number { + return entries.filter((entry) => entry.area === 'unstaged' || entry.area === 'untracked').length +} + +export function getStageablePaths(entries: readonly MobileGitStatusEntry[]): string[] { + return entries.filter(isMobileGitStageableEntry).map((entry) => entry.path) +} + +export function getUnstageablePaths(entries: readonly MobileGitStatusEntry[]): string[] { + return entries.filter((entry) => entry.area === 'staged').map((entry) => entry.path) +} + +export function isMobileGitStageableEntry(entry: MobileGitStatusEntry): boolean { + return ( + (entry.area === 'unstaged' || entry.area === 'untracked') && + entry.conflictStatus !== 'unresolved' + ) +} + +export function isMobileGitDiscardableEntry(entry: MobileGitStatusEntry): boolean { + return entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally' +} + +export function isMobileGitUnavailable(code: string | undefined, message: string | undefined) { + return ( + code === 'forbidden' || + code === 'method_not_found' || + message?.includes('not available to mobile clients') === true + ) +} + +export function isMobileGitTransientRefreshError( + code: string | undefined, + message: string | undefined +) { + const normalized = message?.trim().toLowerCase() + return code === 'request_aborted' || normalized === 'aborting' || normalized === 'request_aborted' +} diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index c9213fd8100..0b9d6e65577 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -155,6 +155,38 @@ describe('git remote operations', () => { ) }) + it('normalizes pull dirty-worktree aborts to a friendly message', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce( + new Error( + 'Command failed: git pull\n' + + 'error: Your local changes to the following files would be overwritten by merge:\n' + + '\tsrc/app.ts\n' + + 'Please commit your changes or stash them before you merge.\n' + + 'Aborting' + ) + ) + + await expect(gitPull('/repo')).rejects.toThrow( + 'Pull would overwrite local changes. Commit, stash, or discard them before pulling.' + ) + }) + + it('normalizes pull untracked-file aborts to a friendly message', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce( + new Error( + 'Command failed: git pull\n' + + 'error: The following untracked working tree files would be overwritten by merge:\n' + + '\tsrc/new.ts\n' + + 'Please move or remove them before you merge.\n' + + 'Aborting' + ) + ) + + await expect(gitPull('/repo')).rejects.toThrow( + 'Pull would overwrite untracked files. Move, remove, or add them before pulling.' + ) + }) + it('runs fetch with prune', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 8d66b351c5b..2ed6eaf58a8 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -595,6 +595,14 @@ describe('OrcaRuntimeRpcServer', () => { const selectCodexAccount = vi.fn().mockResolvedValue({ ok: true }) const removeClaudeAccount = vi.fn().mockResolvedValue({ ok: true }) const readTerminal = vi.fn().mockResolvedValue({ tail: ['ok'] }) + const getRuntimeGitStatus = vi + .fn() + .mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const getRuntimeGitUpstreamStatus = vi + .fn() + .mockResolvedValue({ hasUpstream: true, ahead: 1, behind: 0 }) + const bulkStageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true }) + const bulkUnstageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true }) const runtime = { getRuntimeId: () => 'test-runtime', getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' }), @@ -602,7 +610,11 @@ describe('OrcaRuntimeRpcServer', () => { selectClaudeAccount, selectCodexAccount, removeClaudeAccount, - readTerminal + readTerminal, + getRuntimeGitStatus, + getRuntimeGitUpstreamStatus, + bulkStageRuntimeGitPaths, + bulkUnstageRuntimeGitPaths } as unknown as OrcaRuntimeService const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) server['deviceRegistry'] = new DeviceRegistry(userDataPath) @@ -612,7 +624,7 @@ describe('OrcaRuntimeRpcServer', () => { await server['handleWebSocketMessage']( JSON.stringify({ id: 'req_forbidden', - method: 'git.push', + method: 'git.generateCommitMessage', deviceToken: mobile.token, params: { worktree: 'id:wt-1' } }), @@ -628,6 +640,56 @@ describe('OrcaRuntimeRpcServer', () => { (response) => replies.push(JSON.parse(response) as Record), () => {} ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_git_status', + method: 'git.status', + deviceToken: mobile.token, + params: { worktree: 'id:wt-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_git_push', + method: 'git.push', + deviceToken: mobile.token, + params: { worktree: 'id:wt-1', publish: true } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_git_upstream', + method: 'git.upstreamStatus', + deviceToken: mobile.token, + params: { worktree: 'id:wt-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_git_bulk_stage', + method: 'git.bulkStage', + deviceToken: mobile.token, + params: { worktree: 'id:wt-1', filePaths: ['a.ts', 'b.ts'] } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_git_bulk_unstage', + method: 'git.bulkUnstage', + deviceToken: mobile.token, + params: { worktree: 'id:wt-1', filePaths: ['c.ts'] } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) await server['handleWebSocketMessage']( JSON.stringify({ id: 'req_select_claude', @@ -677,6 +739,13 @@ describe('OrcaRuntimeRpcServer', () => { }) ) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_allowed', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_status', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_push', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_upstream', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_bulk_stage', ok: true })) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_git_bulk_unstage', ok: true }) + ) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_claude', ok: true })) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_codex', ok: true })) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_terminal_read', ok: true })) @@ -690,8 +759,12 @@ describe('OrcaRuntimeRpcServer', () => { expect(selectClaudeAccount).toHaveBeenCalledWith('claude-account') expect(selectCodexAccount).toHaveBeenCalledWith(null) expect(readTerminal).toHaveBeenCalledWith('term-1', { cursor: undefined }) + expect(getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1') + expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined) + expect(getRuntimeGitUpstreamStatus).toHaveBeenCalledWith('id:wt-1') + expect(bulkStageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['a.ts', 'b.ts']) + expect(bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts']) expect(removeClaudeAccount).not.toHaveBeenCalled() - expect(pushRuntimeGit).not.toHaveBeenCalled() }) it('rejects WebSocket requests whose request token differs from the authenticated channel token', async () => { diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 6f940d5b6eb..c0fc8256f2e 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -126,6 +126,17 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.list', 'files.open', 'files.read', + 'git.bulkStage', + 'git.bulkUnstage', + 'git.commit', + 'git.discard', + 'git.fetch', + 'git.pull', + 'git.push', + 'git.stage', + 'git.status', + 'git.unstage', + 'git.upstreamStatus', 'markdown.readTab', 'markdown.saveTab', 'notifications.subscribe', diff --git a/src/shared/git-remote-error.ts b/src/shared/git-remote-error.ts index 717da5d8ea0..07bf656c92d 100644 --- a/src/shared/git-remote-error.ts +++ b/src/shared/git-remote-error.ts @@ -64,6 +64,17 @@ export function normalizeGitErrorMessage(error: unknown, operation?: GitRemoteOp return 'Branch has no upstream. Publish the branch first.' } + if ( + raw.includes('Your local changes to the following files would be overwritten') || + raw.includes('Your local changes would be overwritten') + ) { + return 'Pull would overwrite local changes. Commit, stash, or discard them before pulling.' + } + + if (raw.includes('untracked working tree files would be overwritten')) { + return 'Pull would overwrite untracked files. Move, remove, or add them before pulling.' + } + // Fallthrough: extract only the tail stderr line. `raw` was already // credential-scrubbed at the top of the function, so no further scrub needed. return extractTailLine(raw) diff --git a/src/shared/git-status-types.ts b/src/shared/git-status-types.ts new file mode 100644 index 00000000000..741bf4ce7e9 --- /dev/null +++ b/src/shared/git-status-types.ts @@ -0,0 +1,61 @@ +export type GitFileStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied' +export type GitStagingArea = 'staged' | 'unstaged' | 'untracked' +export type GitConflictKind = + | 'both_modified' + | 'both_added' + | 'both_deleted' + | 'added_by_us' + | 'added_by_them' + | 'deleted_by_us' + | 'deleted_by_them' + +export type GitConflictResolutionStatus = 'unresolved' | 'resolved_locally' +export type GitConflictStatusSource = 'git' | 'session' +export type GitConflictOperation = 'merge' | 'rebase' | 'cherry-pick' | 'unknown' + +// Compatibility note for non-upgraded consumers: +// Any consumer that has not been upgraded to read `conflictStatus` may still +// render `modified` styling via the `status` field (which is a compatibility +// fallback, not a semantic claim). However, such consumers must NOT offer +// file-existence-dependent affordances (diff loading, drag payloads, editable- +// file opening) for entries where `conflictStatus === 'unresolved'` — the file +// may not exist on disk (e.g. both_deleted). This affects file explorer +// decorations, tab badges, and any surface outside Source Control. +// +// `conflictStatusSource` is never set by the main process. The renderer stamps +// 'git' for live u-records and 'session' for Resolved locally state. +export type GitUncommittedEntry = { + path: string + status: GitFileStatus + area: GitStagingArea + oldPath?: string + conflictKind?: GitConflictKind + conflictStatus?: GitConflictResolutionStatus + conflictStatusSource?: GitConflictStatusSource +} + +export type GitStatusEntry = GitUncommittedEntry + +export type GitStatusResult = { + entries: GitStatusEntry[] + conflictOperation: GitConflictOperation + head?: string + branch?: string + // Why: porcelain v2 status already includes upstream/ahead/behind metadata. + // Folding it in lets refresh polling avoid a second pair of git subprocesses. + upstreamStatus?: GitUpstreamStatus + ignoredPaths?: string[] +} + +// Why: when hasUpstream is false, ahead/behind are placeholder zeros, not a +// "sync" signal — callers must check hasUpstream before treating 0/0 as in-sync. +// Kept as a named type because explicit upstream refreshes can still fail for +// reasons unrelated to working-tree status (e.g., no upstream is expected). +export type GitUpstreamStatus = { + hasUpstream: boolean + upstreamName?: string + ahead: number + behind: number +} + +export type GitBranchChangeStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'copied' diff --git a/src/shared/types.ts b/src/shared/types.ts index a0a9fa6c44a..81360ef3e9b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -12,11 +12,25 @@ import type { VoiceSettings } from './speech-types' import type { WorkspaceCleanupUIState } from './workspace-cleanup' import type { GitLabProjectSettings } from './gitlab-types' import type { TaskProvider } from './task-providers' +import type { GitBranchChangeStatus } from './git-status-types' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './telemetry-events' export type { TaskProvider } from './task-providers' +export type { + GitBranchChangeStatus, + GitConflictKind, + GitConflictOperation, + GitConflictResolutionStatus, + GitConflictStatusSource, + GitFileStatus, + GitStagingArea, + GitStatusEntry, + GitStatusResult, + GitUncommittedEntry, + GitUpstreamStatus +} from './git-status-types' // ─── Shell PATH hydration ──────────────────────────────────────────── // Why: shared so the main-side `HydrationResult` discriminator and the @@ -1970,67 +1984,8 @@ export type FsChangedPayload = { } // ─── Git Status ───────────────────────────────────────────── -export type GitFileStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied' -export type GitStagingArea = 'staged' | 'unstaged' | 'untracked' -export type GitConflictKind = - | 'both_modified' - | 'both_added' - | 'both_deleted' - | 'added_by_us' - | 'added_by_them' - | 'deleted_by_us' - | 'deleted_by_them' - -export type GitConflictResolutionStatus = 'unresolved' | 'resolved_locally' -export type GitConflictStatusSource = 'git' | 'session' -export type GitConflictOperation = 'merge' | 'rebase' | 'cherry-pick' | 'unknown' - -// Compatibility note for non-upgraded consumers: -// Any consumer that has not been upgraded to read `conflictStatus` may still -// render `modified` styling via the `status` field (which is a compatibility -// fallback, not a semantic claim). However, such consumers must NOT offer -// file-existence-dependent affordances (diff loading, drag payloads, editable- -// file opening) for entries where `conflictStatus === 'unresolved'` — the file -// may not exist on disk (e.g. both_deleted). This affects file explorer -// decorations, tab badges, and any surface outside Source Control. -// -// `conflictStatusSource` is never set by the main process. The renderer stamps -// 'git' for live u-records and 'session' for Resolved locally state. -export type GitUncommittedEntry = { - path: string - status: GitFileStatus - area: GitStagingArea - oldPath?: string - conflictKind?: GitConflictKind - conflictStatus?: GitConflictResolutionStatus - conflictStatusSource?: GitConflictStatusSource -} - -export type GitStatusEntry = GitUncommittedEntry - -export type GitStatusResult = { - entries: GitStatusEntry[] - conflictOperation: GitConflictOperation - head?: string - branch?: string - // Why: porcelain v2 status already includes upstream/ahead/behind metadata. - // Folding it in lets refresh polling avoid a second pair of git subprocesses. - upstreamStatus?: GitUpstreamStatus - ignoredPaths?: string[] -} - -// Why: when hasUpstream is false, ahead/behind are placeholder zeros, not a -// "sync" signal — callers must check hasUpstream before treating 0/0 as in-sync. -// Kept as a named type because explicit upstream refreshes can still fail for -// reasons unrelated to working-tree status (e.g., no upstream is expected). -export type GitUpstreamStatus = { - hasUpstream: boolean - upstreamName?: string - ahead: number - behind: number -} - -export type GitBranchChangeStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'copied' +// Re-exported from git-status-types.ts so mobile can share the runtime git +// wire contract without importing this desktop-oriented aggregate type module. export type GitBranchChangeEntry = { path: string