diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx index 11a293f53..01f362890 100644 --- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx @@ -27,17 +27,32 @@ import { MoreHorizontal, Plus, RefreshCw, - Trash2 + Trash2, + X } from 'lucide-react-native' import { useHostClient } from '../../../../src/transport/client-context' +import type { RpcClient } from '../../../../src/transport/rpc-client' import type { RpcSuccess } from '../../../../src/transport/types' import { ActionSheetModal, type ActionSheetAction } from '../../../../src/components/ActionSheetModal' import { ConfirmModal } from '../../../../src/components/ConfirmModal' +import { BottomDrawer } from '../../../../src/components/BottomDrawer' import { triggerError, triggerSelection, triggerSuccess } from '../../../../src/platform/haptics' import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme' +import { + buildMobileDiffLines, + type MobileDiffLine +} from '../../../../src/session/mobile-diff-lines' +import { + buildMobileBranchCompareSection, + canOpenMobileBranchCompareDiff, + formatMobileBranchCompareSummary, + type MobileGitBranchChangeEntry, + type MobileGitBranchCompareResult, + type MobileGitBranchCompareSummary +} from '../../../../src/source-control/mobile-branch-compare' import { MOBILE_GIT_STATUS_LABELS, buildMobileSourceControlSections, @@ -86,6 +101,43 @@ type MobileGitStatusEntryView = MobileGitStatusEntry & { unstageActionId: string } +type MobileBranchCompareState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'ready'; result: MobileGitBranchCompareResult } + | { kind: 'error'; message: string } + +type MobileBranchEntryView = MobileGitBranchChangeEntry & { + canOpen: boolean +} + +type MobileBranchDiffPreviewState = + | { kind: 'loading'; entry: MobileGitBranchChangeEntry } + | { + kind: 'ready' + entry: MobileGitBranchChangeEntry + summary: MobileGitBranchCompareSummary + lines: MobileDiffLine[] + truncated: boolean + } + | { kind: 'error'; entry: MobileGitBranchChangeEntry; message: string } + +type RuntimeRepoSummary = { + id: string + worktreeBaseRef?: string | null +} + +type RepoBaseRefDefaultResult = { + defaultBaseRef: string | null + remoteCount: number +} + +type GitDiffTextResult = { + kind: 'text' + originalContent: string + modifiedContent: string +} + const KEYBOARD_COMMIT_BAR_CLEARANCE = 10 const SELECTOR_RETRY_COUNT = 3 const SELECTOR_RETRY_DELAY_MS = 250 @@ -98,6 +150,45 @@ function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } +function getRepoIdFromMobileWorktreeId(id: string): string { + // Why: mobile cannot import desktop shared modules in its standalone tsc run, + // but the runtime worktree id wire format is still `${repoId}::${path}`. + const separatorIdx = id.indexOf('::') + return separatorIdx === -1 ? id : id.slice(0, separatorIdx) +} + +async function resolveMobileBranchCompareBaseRef( + client: RpcClient, + worktreeId: string +): Promise { + const repoId = getRepoIdFromMobileWorktreeId(worktreeId) + if (!repoId) { + return null + } + + let repoBaseRef: string | null = null + const repoResponse = await client.sendRequest('repo.list') + if (repoResponse.ok) { + const repos = ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos + const repo = repos?.find((candidate) => candidate.id === repoId) + repoBaseRef = repo?.worktreeBaseRef?.trim() || null + } + + if (repoBaseRef) { + return repoBaseRef + } + + const defaultResponse = await client.sendRequest('repo.baseRefDefault', { repo: `id:${repoId}` }) + if (!defaultResponse.ok) { + if (isMobileGitUnavailable(defaultResponse.error?.code, defaultResponse.error?.message)) { + return null + } + throw new Error(defaultResponse.error?.message || 'Unable to resolve branch base') + } + const result = (defaultResponse as RpcSuccess).result as RepoBaseRefDefaultResult + return result.defaultBaseRef?.trim() || null +} + function getWorktreeLabel(name: string | undefined, worktreeId: string): string { if (name?.trim()) { return name.trim() @@ -133,6 +224,27 @@ function statusColor(status: MobileGitFileStatus): string { } } +function formatBranchEntryMeta(entry: MobileGitBranchChangeEntry): string | null { + const stats = + entry.added !== undefined || entry.removed !== undefined + ? `+${entry.added ?? 0} -${entry.removed ?? 0}` + : null + if (entry.oldPath) { + return stats ? `from ${entry.oldPath}; ${stats}` : `from ${entry.oldPath}` + } + return stats +} + +function diffLinePrefix(kind: MobileDiffLine['kind']): string { + if (kind === 'add') return '+' + if (kind === 'delete') return '-' + return ' ' +} + +function diffLineNumber(line: MobileDiffLine): string { + return String(line.newLineNumber ?? line.oldLineNumber ?? '') +} + export default function MobileSourceControlScreen() { const params = useLocalSearchParams<{ hostId?: string | string[] @@ -148,6 +260,12 @@ export default function MobileSourceControlScreen() { const insets = useSafeAreaInsets() const { client, state: connState } = useHostClient(hostId) const [screenState, setScreenState] = useState({ kind: 'loading' }) + const [branchCompareState, setBranchCompareState] = useState({ + kind: 'idle' + }) + const [branchDiffPreview, setBranchDiffPreview] = useState( + null + ) const [busyAction, setBusyAction] = useState(null) const [commitMessage, setCommitMessage] = useState('') const [discardTarget, setDiscardTarget] = useState(null) @@ -155,20 +273,26 @@ export default function MobileSourceControlScreen() { const [actionError, setActionError] = useState(null) const [keyboardLift, setKeyboardLift] = useState(0) const [openingPath, setOpeningPath] = useState(null) + const [openingBranchPath, setOpeningBranchPath] = useState(null) const busyActionRef = useRef(null) const currentStatusIdentityRef = useRef('') + const currentBranchCompareIdentityRef = useRef('') const loadGenerationRef = useRef(0) + const branchCompareGenerationRef = useRef(0) const mountedRef = useRef(true) const openingPathRef = useRef(null) + const openingBranchPathRef = useRef(null) const statusLoadInFlightRef = useRef(null) const worktreeLabel = getWorktreeLabel(name, worktreeId) const statusIdentityKey = `${hostId}\0${worktreeId}` currentStatusIdentityRef.current = statusIdentityKey + currentBranchCompareIdentityRef.current = statusIdentityKey useEffect(() => { return () => { mountedRef.current = false loadGenerationRef.current += 1 + branchCompareGenerationRef.current += 1 } }, []) @@ -188,6 +312,63 @@ export default function MobileSourceControlScreen() { } }, [insets.bottom]) + const loadBranchCompare = useCallback( + async (options?: { preserveReadyOnFailure?: boolean }) => { + const loadKey = statusIdentityKey + const generation = branchCompareGenerationRef.current + 1 + branchCompareGenerationRef.current = generation + const isCurrentLoad = () => + mountedRef.current && + branchCompareGenerationRef.current === generation && + currentBranchCompareIdentityRef.current === loadKey + + if (!worktreeId || !client || connState !== 'connected') { + if (isCurrentLoad()) { + setBranchCompareState({ kind: 'idle' }) + } + return false + } + + setBranchCompareState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' })) + try { + const baseRef = await resolveMobileBranchCompareBaseRef(client, worktreeId) + if (!isCurrentLoad()) return false + if (!baseRef) { + setBranchCompareState({ kind: 'idle' }) + return true + } + const response = await client.sendRequest('git.branchCompare', { + worktree: `id:${worktreeId}`, + baseRef + }) + if (!isCurrentLoad()) return false + if (!response.ok) { + if (isMobileGitUnavailable(response.error?.code, response.error?.message)) { + setBranchCompareState({ kind: 'idle' }) + return false + } + throw new Error(response.error?.message || 'Unable to load committed changes') + } + setBranchCompareState({ + kind: 'ready', + result: (response as RpcSuccess).result as MobileGitBranchCompareResult + }) + return true + } catch (err) { + if (!isCurrentLoad()) return false + const message = err instanceof Error ? err.message : 'Unable to load committed changes' + setBranchCompareState((prev) => { + if (options?.preserveReadyOnFailure && prev.kind === 'ready') { + return prev + } + return { kind: 'error', message } + }) + return false + } + }, + [client, connState, statusIdentityKey, worktreeId] + ) + const loadStatus = useCallback( async (options?: LoadStatusOptions) => { const loadKey = statusIdentityKey @@ -230,6 +411,7 @@ export default function MobileSourceControlScreen() { if (response.ok) { const result = (response as RpcSuccess).result as MobileGitStatusResult setScreenState({ kind: 'ready', status: result }) + void loadBranchCompare({ preserveReadyOnFailure: true }) if (options?.clearActionErrorOnSuccess !== false) { setActionError(null) } @@ -278,7 +460,7 @@ export default function MobileSourceControlScreen() { } } }, - [client, connState, statusIdentityKey, worktreeId] + [client, connState, loadBranchCompare, statusIdentityKey, worktreeId] ) useEffect(() => { @@ -301,6 +483,31 @@ export default function MobileSourceControlScreen() { [entries] ) const sections = useMemo(() => buildMobileSourceControlSections(derivedEntries), [derivedEntries]) + const branchCompareResult = branchCompareState.kind === 'ready' ? branchCompareState.result : null + const branchCompareSection = useMemo( + () => buildMobileBranchCompareSection(branchCompareResult?.entries ?? []), + [branchCompareResult] + ) + const branchCompareSummaryText = branchCompareResult + ? formatMobileBranchCompareSummary(branchCompareResult.summary) + : null + const branchCompareCanOpen = branchCompareResult + ? canOpenMobileBranchCompareDiff(branchCompareResult.summary) + : false + const branchEntries = useMemo( + () => + (branchCompareSection?.data ?? []).map((entry) => ({ + ...entry, + canOpen: branchCompareCanOpen + })), + [branchCompareCanOpen, branchCompareSection] + ) + const shouldShowBranchCompareSection = + branchEntries.length > 0 || + branchCompareState.kind === 'loading' || + branchCompareState.kind === 'error' || + (branchCompareResult !== null && branchCompareResult.summary.status !== 'ready') + const hasVisibleChanges = sections.length > 0 || shouldShowBranchCompareSection const stageablePaths = useMemo(() => getStageablePaths(entries), [entries]) const unstageablePaths = useMemo(() => getUnstageablePaths(entries), [entries]) const stagedCount = useMemo(() => countStagedEntries(entries), [entries]) @@ -622,13 +829,81 @@ export default function MobileSourceControlScreen() { [client, connState, hostId, name, origin, router, worktreeId] ) + const openBranchDiff = useCallback( + async (entry: MobileGitBranchChangeEntry) => { + if (openingBranchPathRef.current || openingPathRef.current || busyActionRef.current) return + if (!client || connState !== 'connected') { + if (!mountedRef.current) return + setActionError('Waiting for desktop...') + return + } + if (branchCompareState.kind !== 'ready') { + return + } + const summary = branchCompareState.result.summary + if (!canOpenMobileBranchCompareDiff(summary) || !summary.headOid || !summary.mergeBase) { + return + } + + openingBranchPathRef.current = entry.path + setOpeningBranchPath(entry.path) + setBranchDiffPreview({ kind: 'loading', entry }) + try { + const response = await client.sendRequest('git.branchDiff', { + worktree: `id:${worktreeId}`, + filePath: entry.path, + ...(entry.oldPath ? { oldPath: entry.oldPath } : {}), + compare: { + baseRef: summary.baseRef, + ...(summary.baseOid ? { baseOid: summary.baseOid } : {}), + headOid: summary.headOid, + mergeBase: summary.mergeBase + } + }) + if (!response.ok) { + throw new Error(response.error?.message || 'Unable to load committed diff') + } + const result = (response as RpcSuccess).result as GitDiffTextResult | { kind: 'binary' } + if (result.kind !== 'text') { + throw new Error('Binary branch diff preview unavailable on mobile') + } + const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent) + if (!mountedRef.current) return + setBranchDiffPreview({ + kind: 'ready', + entry, + summary, + lines: diff.lines, + truncated: diff.truncated + }) + triggerSelection() + } catch (err) { + if (!mountedRef.current) return + triggerError() + setBranchDiffPreview({ + kind: 'error', + entry, + message: err instanceof Error ? err.message : 'Unable to load committed diff' + }) + } finally { + if (openingBranchPathRef.current === entry.path) { + openingBranchPathRef.current = null + if (mountedRef.current) { + setOpeningBranchPath(null) + } + } + } + }, + [branchCompareState, client, connState, 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 busy = busyAction !== null || openingPath !== null || openingBranchPath !== null const commitHint = !hasStaged ? 'Stage at least one file' : !hasMessage @@ -749,6 +1024,8 @@ export default function MobileSourceControlScreen() { }, [ busyAction, commitMessage, + openingBranchPath, + openingPath, runActionSheetCommit, runActionSheetCommitSequence, runActionSheetCommitSync, @@ -771,7 +1048,8 @@ export default function MobileSourceControlScreen() { busyAction === item.unstageActionId || busyAction === item.discardActionId || openingPath === item.path - const rowDisabled = !item.canOpen || busyAction !== null || openingPath !== null + const rowDisabled = + !item.canOpen || busyAction !== null || openingPath !== null || openingBranchPath !== null return ( [ @@ -817,10 +1095,11 @@ export default function MobileSourceControlScreen() { [ styles.iconButton, - (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled, + (busyAction !== null || openingPath !== null || openingBranchPath !== null) && + styles.iconButtonDisabled, pressed && styles.iconButtonPressed ]} - disabled={busyAction !== null || openingPath !== null} + disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null} onPress={() => void runGitAction(item.unstageActionId, 'git.unstage', { filePath: item.path }) } @@ -835,10 +1114,13 @@ export default function MobileSourceControlScreen() { [ styles.iconButton, - (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled, + (busyAction !== null || openingPath !== null || openingBranchPath !== null) && + styles.iconButtonDisabled, pressed && styles.iconButtonPressed ]} - disabled={busyAction !== null || openingPath !== null} + disabled={ + busyAction !== null || openingPath !== null || openingBranchPath !== null + } onPress={() => void runGitAction(item.stageActionId, 'git.stage', { filePath: item.path }) } @@ -852,10 +1134,13 @@ export default function MobileSourceControlScreen() { [ styles.iconButton, - (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled, + (busyAction !== null || openingPath !== null || openingBranchPath !== null) && + styles.iconButtonDisabled, pressed && styles.iconButtonPressed ]} - disabled={busyAction !== null || openingPath !== null} + disabled={ + busyAction !== null || openingPath !== null || openingBranchPath !== null + } onPress={() => setDiscardTarget(item)} hitSlop={8} accessibilityLabel={`Discard ${item.path}`} @@ -868,7 +1153,7 @@ export default function MobileSourceControlScreen() { ) }, - [busyAction, openFile, openingPath, runGitAction] + [busyAction, openFile, openingBranchPath, openingPath, runGitAction] ) const keyExtractor = useCallback( @@ -886,6 +1171,170 @@ export default function MobileSourceControlScreen() { [] ) + const renderBranchCompareFooter = useCallback(() => { + if (!shouldShowBranchCompareSection) { + return null + } + + return ( + + + + Committed on Branch + {branchCompareSummaryText ? ( + + {branchCompareSummaryText} + + ) : null} + + {branchEntries.length} + + {branchCompareState.kind === 'loading' ? ( + + + Loading committed changes... + + ) : branchCompareState.kind === 'error' ? ( + + {branchCompareState.message} + + ) : branchCompareResult && branchCompareResult.summary.status !== 'ready' ? ( + + + {branchCompareResult.summary.errorMessage ?? 'Committed changes unavailable.'} + + + ) : ( + branchEntries.map((entry) => { + const rowBusy = openingBranchPath === entry.path + const rowDisabled = + !entry.canOpen || + busyAction !== null || + openingPath !== null || + openingBranchPath !== null + const meta = formatBranchEntryMeta(entry) + return ( + [ + styles.fileRow, + pressed && entry.canOpen && styles.fileRowPressed, + rowDisabled && styles.fileRowDisabled, + !entry.canOpen && styles.fileRowUnavailable + ]} + onPress={() => void openBranchDiff(entry)} + disabled={rowDisabled} + accessibilityLabel={`Open committed change ${entry.path}`} + > + + + {MOBILE_GIT_STATUS_LABELS[entry.status]} + + + + + + {entry.path} + + {meta ? ( + + {meta} + + ) : null} + + {rowBusy ? : null} + + ) + }) + )} + + ) + }, [ + branchCompareResult, + branchCompareState, + branchCompareSummaryText, + branchEntries, + busyAction, + openBranchDiff, + openingBranchPath, + openingPath, + shouldShowBranchCompareSection + ]) + + const renderBranchDiffPreview = useCallback(() => { + if (!branchDiffPreview) { + return null + } + const entry = branchDiffPreview.entry + return ( + setBranchDiffPreview(null)} + dragContentToDismiss={false} + zIndex={1100} + > + + + + {entry.path} + + + {branchDiffPreview.kind === 'ready' + ? `${branchDiffPreview.summary.baseRef}..HEAD` + : 'Committed on branch'} + + + [styles.diffCloseButton, pressed && styles.iconButtonPressed]} + onPress={() => setBranchDiffPreview(null)} + hitSlop={8} + accessibilityLabel="Close committed diff preview" + > + + + + + {branchDiffPreview.kind === 'loading' ? ( + + + + ) : branchDiffPreview.kind === 'error' ? ( + + Unable to Load Diff + {branchDiffPreview.message} + + ) : ( + + {branchDiffPreview.truncated ? ( + Diff truncated for mobile preview. + ) : null} + {branchDiffPreview.lines.map((line, index) => ( + + {diffLineNumber(line)} + {diffLinePrefix(line.kind)} + {line.text || ' '} + + ))} + + )} + + ) + }, [branchDiffPreview]) + return ( @@ -909,11 +1358,12 @@ export default function MobileSourceControlScreen() { [ styles.refreshButton, - (busyAction !== null || openingPath !== null) && styles.refreshButtonDisabled, + (busyAction !== null || openingPath !== null || openingBranchPath !== null) && + styles.refreshButtonDisabled, pressed && styles.refreshButtonPressed ]} onPress={() => void loadStatus()} - disabled={busyAction !== null || openingPath !== null} + disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null} hitSlop={8} accessibilityLabel="Refresh source control" > @@ -953,6 +1403,9 @@ export default function MobileSourceControlScreen() { {unstagedCount} changed {stagedCount} staged + {branchEntries.length > 0 ? ( + {branchEntries.length} on branch + ) : null} {status && status.conflictOperation !== 'unknown' ? ( {status.conflictOperation} ) : null} @@ -968,13 +1421,19 @@ export default function MobileSourceControlScreen() { [ styles.bulkButton, - (stageablePaths.length === 0 || busyAction !== null || openingPath !== null) && + (stageablePaths.length === 0 || + busyAction !== null || + openingPath !== null || + openingBranchPath !== null) && styles.bulkButtonDisabled, pressed && styles.bulkButtonPressed ]} onPress={() => void stageAll()} disabled={ - busyAction !== null || openingPath !== null || stageablePaths.length === 0 + busyAction !== null || + openingPath !== null || + openingBranchPath !== null || + stageablePaths.length === 0 } > {busyAction === 'stage-all' ? ( @@ -987,13 +1446,19 @@ export default function MobileSourceControlScreen() { [ styles.bulkButton, - (unstageablePaths.length === 0 || busyAction !== null || openingPath !== null) && + (unstageablePaths.length === 0 || + busyAction !== null || + openingPath !== null || + openingBranchPath !== null) && styles.bulkButtonDisabled, pressed && styles.bulkButtonPressed ]} onPress={() => void unstageAll()} disabled={ - busyAction !== null || openingPath !== null || unstageablePaths.length === 0 + busyAction !== null || + openingPath !== null || + openingBranchPath !== null || + unstageablePaths.length === 0 } > {busyAction === 'unstage-all' ? ( @@ -1007,10 +1472,11 @@ export default function MobileSourceControlScreen() { style={({ pressed }) => [ styles.bulkMenuButton, pressed && styles.bulkButtonPressed, - (busyAction !== null || openingPath !== null) && styles.bulkButtonDisabled + (busyAction !== null || openingPath !== null || openingBranchPath !== null) && + styles.bulkButtonDisabled ]} onPress={() => setShowActionSheet(true)} - disabled={busyAction !== null || openingPath !== null} + disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null} hitSlop={8} accessibilityLabel="Open source control actions" > @@ -1019,7 +1485,7 @@ export default function MobileSourceControlScreen() { - {entries.length === 0 ? ( + {!hasVisibleChanges ? ( No Changes Working tree is clean. @@ -1030,6 +1496,7 @@ export default function MobileSourceControlScreen() { renderItem={renderItem} keyExtractor={keyExtractor} renderSectionHeader={renderSectionHeader} + ListFooterComponent={renderBranchCompareFooter} stickySectionHeadersEnabled={false} contentContainerStyle={styles.listContent} /> @@ -1062,7 +1529,9 @@ export default function MobileSourceControlScreen() { onChangeText={setCommitMessage} placeholder="Commit message" placeholderTextColor={colors.textMuted} - editable={busyAction === null && openingPath === null} + editable={ + busyAction === null && openingPath === null && openingBranchPath === null + } returnKeyType="done" onSubmitEditing={() => void commit()} /> @@ -1073,7 +1542,8 @@ export default function MobileSourceControlScreen() { (!commitMessage.trim() || stagedCount === 0 || busyAction !== null || - openingPath !== null) && + openingPath !== null || + openingBranchPath !== null) && styles.commitButtonDisabled, pressed && styles.commitButtonPressed ]} @@ -1082,7 +1552,8 @@ export default function MobileSourceControlScreen() { !commitMessage.trim() || stagedCount === 0 || busyAction !== null || - openingPath !== null + openingPath !== null || + openingBranchPath !== null } > {busyAction === 'commit' ? ( @@ -1096,6 +1567,8 @@ export default function MobileSourceControlScreen() { )} + {renderBranchDiffPreview()} + { + it('keeps the mobile branch compare type in lockstep with the runtime contract', () => { + expectTypeOf().toEqualTypeOf() + }) + + it('sorts committed branch entries by path', () => { + const section = buildMobileBranchCompareSection([ + { path: 'zeta.ts', status: 'modified' }, + { path: 'alpha.ts', status: 'added' } + ]) + + expect(section?.title).toBe('Committed on Branch') + expect(section?.data.map((entry) => entry.path)).toEqual(['alpha.ts', 'zeta.ts']) + }) + + it('summarizes ready branch compares', () => { + expect( + formatMobileBranchCompareSummary({ + baseRef: 'origin/main', + baseOid: 'a'.repeat(40), + compareRef: 'HEAD', + headOid: 'b'.repeat(40), + mergeBase: 'c'.repeat(40), + changedFiles: 2, + commitsAhead: 1, + status: 'ready' + }) + ).toBe('2 files - 1 commit - vs origin/main') + }) + + it('only opens branch diffs when compare object ids are available', () => { + expect( + canOpenMobileBranchCompareDiff({ + baseRef: 'origin/main', + baseOid: 'a'.repeat(40), + compareRef: 'HEAD', + headOid: 'b'.repeat(40), + mergeBase: 'c'.repeat(40), + changedFiles: 1, + status: 'ready' + }) + ).toBe(true) + + expect( + canOpenMobileBranchCompareDiff({ + baseRef: 'origin/main', + baseOid: null, + compareRef: 'HEAD', + headOid: null, + mergeBase: null, + changedFiles: 0, + status: 'unborn-head' + }) + ).toBe(false) + }) +}) diff --git a/mobile/src/source-control/mobile-branch-compare.ts b/mobile/src/source-control/mobile-branch-compare.ts new file mode 100644 index 000000000..060ee768e --- /dev/null +++ b/mobile/src/source-control/mobile-branch-compare.ts @@ -0,0 +1,53 @@ +import type { + GitBranchChangeEntry, + GitBranchCompareResult, + GitBranchCompareSummary +} from '../../../src/shared/types' + +export type MobileGitBranchChangeEntry = GitBranchChangeEntry +export type MobileGitBranchCompareSummary = GitBranchCompareSummary +export type MobileGitBranchCompareResult = GitBranchCompareResult + +export type MobileBranchCompareSection< + TEntry extends MobileGitBranchChangeEntry = MobileGitBranchChangeEntry +> = { + title: 'Committed on Branch' + data: TEntry[] +} + +function compareBranchEntries( + a: MobileGitBranchChangeEntry, + b: MobileGitBranchChangeEntry +): number { + return a.path.localeCompare(b.path, undefined, { numeric: true }) +} + +export function buildMobileBranchCompareSection( + entries: readonly TEntry[] +): MobileBranchCompareSection | null { + if (entries.length === 0) { + return null + } + return { + title: 'Committed on Branch', + data: [...entries].sort(compareBranchEntries) + } +} + +export function formatMobileBranchCompareSummary( + summary: MobileGitBranchCompareSummary +): string | null { + if (summary.status !== 'ready') { + return summary.errorMessage ?? null + } + const parts = [`${summary.changedFiles} ${summary.changedFiles === 1 ? 'file' : 'files'}`] + if (summary.commitsAhead !== undefined) { + parts.push(`${summary.commitsAhead} ${summary.commitsAhead === 1 ? 'commit' : 'commits'}`) + } + parts.push(`vs ${summary.baseRef}`) + return parts.join(' - ') +} + +export function canOpenMobileBranchCompareDiff(summary: MobileGitBranchCompareSummary): boolean { + return summary.status === 'ready' && Boolean(summary.headOid && summary.mergeBase) +} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 4623c3cb0..b6cabf7bb 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -150,6 +150,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'git.abortRebase', 'git.bulkStage', 'git.bulkUnstage', + 'git.branchCompare', + 'git.branchDiff', 'git.commit', 'git.discard', 'git.diff', @@ -237,6 +239,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'preflight.check', 'preflight.detectAgents', 'preflight.detectRemoteAgents', + 'repo.baseRefDefault', 'repo.hooks', 'repo.list', 'repo.saveSparsePreset',