mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Add mobile source control actions (#2193)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Folder,
|
||||
File,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Mic,
|
||||
Monitor,
|
||||
Plus,
|
||||
@@ -2372,6 +2373,19 @@ export default function SessionScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [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"
|
||||
>
|
||||
<GitBranch size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,10 @@ export default function HostGroupLayout() {
|
||||
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
|
||||
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
|
||||
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
|
||||
<Stack.Screen
|
||||
name="[hostId]/source-control/[worktreeId]"
|
||||
options={{ title: 'Source Control' }}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string>): 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<string>): 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}`))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<View key={action.label}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<Pressable
|
||||
style={({ pressed }) => [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}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.actionText, action.destructive && styles.actionTextDestructive]}
|
||||
>
|
||||
{action.label}
|
||||
</Text>
|
||||
<View style={styles.actionTextBlock}>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionText,
|
||||
action.destructive && styles.actionTextDestructive,
|
||||
action.disabled && styles.actionTextDisabled
|
||||
]}
|
||||
>
|
||||
{action.label}
|
||||
</Text>
|
||||
{action.hint ? <Text style={styles.actionHint}>{action.hint}</Text> : null}
|
||||
</View>
|
||||
{action.loading ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
@@ -81,7 +99,7 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content
|
||||
|
||||
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<BottomDrawer visible={visible} onClose={onClose} dragContentToDismiss>
|
||||
<ActionSheetContent title={title} message={message} actions={actions} onClose={onClose} />
|
||||
</BottomDrawer>
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<MountedBottomDrawer
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onHidden={() => setMounted(false)}
|
||||
dragContentToDismiss={dragContentToDismiss}
|
||||
>
|
||||
{children}
|
||||
</MountedBottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Animated.View style={[styles.overlay, pointerStyle]} accessibilityViewIsModal aria-modal>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
@@ -173,22 +256,53 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
|
||||
drawerStyle
|
||||
]}
|
||||
>
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
{dragContentToDismiss ? (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<GestureDetector gesture={contentPanGesture}>
|
||||
<Animated.View collapsable={false}>
|
||||
<GestureDetector gesture={scrollGesture}>
|
||||
<Animated.ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</>
|
||||
)}
|
||||
<View style={styles.bottomExtension} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
@@ -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<MobileGitStatusResult>().toEqualTypeOf<GitStatusResult>()
|
||||
})
|
||||
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<TEntry extends MobileGitStatusEntry = MobileGitStatusEntry> =
|
||||
{
|
||||
area: MobileGitStagingArea
|
||||
title: string
|
||||
data: TEntry[]
|
||||
}
|
||||
|
||||
const AREA_ORDER: MobileGitStagingArea[] = ['unstaged', 'untracked', 'staged']
|
||||
|
||||
const AREA_TITLES: Record<MobileGitStagingArea, string> = {
|
||||
unstaged: 'Changes',
|
||||
untracked: 'Untracked Files',
|
||||
staged: 'Staged Changes'
|
||||
}
|
||||
|
||||
export const MOBILE_GIT_STATUS_LABELS: Record<MobileGitFileStatus, string> = {
|
||||
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<TEntry extends MobileGitStatusEntry>(
|
||||
entries: readonly TEntry[]
|
||||
): MobileSourceControlSection<TEntry>[] {
|
||||
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'
|
||||
}
|
||||
@@ -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: '' })
|
||||
|
||||
|
||||
@@ -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<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
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<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
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<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
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<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
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<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
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<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
+16
-61
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user