import { type GitActionRequestInput, buildMenuItems, getGitActionDisabledReason, requiresDefaultBranchConfirmation, } from "@t3tools/client-runtime/state/vcs"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { CommonActions, StackActions, useNavigation, type StaticScreenProps, } from "@react-navigation/native"; import { SymbolView } from "../../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text } from "../../../components/AppText"; import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader"; import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; import { vcsEnvironment } from "../../../state/vcs"; import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation"; import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); type GitOverviewSheetProps = StaticScreenProps<{ readonly environmentId: string; readonly threadId: string; }> & { readonly headerInset?: number; readonly presentation?: "sheet" | "inspector"; }; export function GitOverviewSheet(props: GitOverviewSheetProps) { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const presentation = props.presentation ?? "sheet"; const isInspector = presentation === "inspector"; const environmentId = EnvironmentId.make(props.route.params.environmentId); const threadId = ThreadId.make(props.route.params.threadId); const { selectedThread } = useThreadSelection(); const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const iconColor = useThemeColor("--color-icon"); const foregroundColor = String(useThemeColor("--color-foreground")); const sheetColor = String(useThemeColor("--color-sheet")); const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null ? vcsEnvironment.status({ environmentId: selectedThread.environmentId, input: { cwd: selectedThreadCwd }, }) : null, ); const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; const currentStatusSummary = statusSummary(gitStatus.data); const currentWorktreePath = selectedThreadWorktreePath; const gitOperationLabel = gitState.gitOperationLabel; const busy = gitOperationLabel !== null; const isRepo = gitStatus.data?.isRepo ?? true; const hasPrimaryRemote = gitStatus.data?.hasPrimaryRemote ?? false; const isDefaultRef = gitStatus.data?.isDefaultRef ?? false; const menuItems = useMemo( () => (isRepo ? buildMenuItems(gitStatus.data, busy, hasPrimaryRemote) : []), [busy, gitStatus.data, hasPrimaryRemote, isRepo], ); const sheetMenuItems = useMemo( () => menuItems.map((item) => ({ item, disabledReason: getGitActionDisabledReason({ item, gitStatus: gitStatus.data, isBusy: busy, hasOriginRemote: hasPrimaryRemote, }), })), [busy, gitStatus.data, hasPrimaryRemote, menuItems], ); useEffect(() => { void gitActions.refreshSelectedThreadGitStatus({ quiet: true }); }, [gitActions]); const openExistingPr = useCallback(async () => { const prUrl = gitStatus.data?.pr?.state === "open" ? gitStatus.data.pr.url : null; if (!prUrl) { Alert.alert("No open PR", "This branch does not have an open pull request."); return; } if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) { Alert.alert("Unable to open PR", "The pull request could not be opened."); } }, [gitStatus.data]); const runActionWithPrompt = useCallback( async (input: GitActionRequestInput) => { const confirmableAction = input.action === "push" || input.action === "create_pr" || input.action === "commit_push" || input.action === "commit_push_pr" ? input.action : null; const branchName = gitStatus.data?.refName; if ( branchName && confirmableAction && !input.featureBranch && requiresDefaultBranchConfirmation(input.action, isDefaultRef) ) { navigation.navigate("GitConfirm", { environmentId: String(environmentId), threadId: String(threadId), confirmAction: confirmableAction, branchName, includesCommit: String( input.action === "commit_push" || input.action === "commit_push_pr", ), }); return; } if (!isInspector) { navigation.goBack(); } await gitActions.onRunSelectedThreadGitAction(input); }, [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, navigation, threadId], ); const onPressMenuItem = useCallback( async (item: (typeof menuItems)[number]) => { if (item.disabled) return; if (item.kind === "open_pr") { await openExistingPr(); return; } if (item.dialogAction === "commit") { navigation.navigate("GitCommit", { environmentId: String(environmentId), threadId: String(threadId), }); return; } if (item.dialogAction === "push") { await runActionWithPrompt({ action: "push" }); return; } if (item.dialogAction === "create_pr") { await runActionWithPrompt({ action: "create_pr" }); } }, [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId], ); // Status facts live on the relevant rows instead of crowding the header // subtitle: files changed → Commit, ahead → Push, PR → View PR, behind → Pull. const rowStatusDetail = useCallback( (item: (typeof menuItems)[number]): string | undefined => { const status = gitStatus.data; if (status == null) { return undefined; } if (item.dialogAction === "commit" && status.hasWorkingTreeChanges) { const fileCount = status.workingTree?.files.length ?? 0; return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`; } if (item.dialogAction === "push" && (status.aheadCount ?? 0) > 0) { const ahead = status.aheadCount ?? 0; return `${ahead} commit${ahead === 1 ? "" : "s"} ahead`; } if (item.kind === "open_pr" && status.pr?.number != null) { return `PR #${status.pr.number} ${status.pr.state ?? "open"}`; } return undefined; }, [gitStatus.data, menuItems], ); const behindCount = gitStatus.data?.behindCount ?? 0; // Deterministic pull-to-refresh state. Tying RefreshControl to the query's // isPending flag left the spinner stuck (the status query reports pending // during quiet background refreshes too). const [isPullRefreshing, setIsPullRefreshing] = useState(false); const handlePullRefresh = useCallback(async () => { setIsPullRefreshing(true); try { await gitActions.refreshSelectedThreadGitStatus(); } finally { setIsPullRefreshing(false); } }, [gitActions]); const content = ( void handlePullRefresh()} /> } > {sheetMenuItems.map(({ item, disabledReason }, index) => ( {index > 0 ? : null} void onPressMenuItem(item)} /> ))} {behindCount > 0 ? ( <> void gitActions.onPullSelectedThreadBranch()} /> ) : null} { const params = { environmentId, threadId }; navigation.dispatch( resolveGitOverviewReviewNavigationAction(presentation) === "replace" ? StackActions.replace("ThreadReview", params) : CommonActions.navigate("ThreadReview", params), ); }} /> navigation.navigate("GitBranches", { environmentId: String(environmentId), threadId: String(threadId), }) } /> {currentWorktreePath ? : null} ); if (isInspector && Platform.OS === "ios") { return ( {content} ); } if (Platform.OS === "ios") { // Compact form sheet: a plain screen presented as formSheet never renders a // stack header, so — like the Settings sheet — the header must come from a // nested native stack INSIDE the sheet. This reuses the exact structure of the // inspector branch below: branch as the title, status summary as the native // subtitle, refresh as a header button. return ( {content} ); } return ( {isInspector ? ( ) : null} {isInspector ? ( void gitActions.refreshSelectedThreadGitStatus()} > Repository {currentBranchLabel} {currentStatusSummary} ) : ( navigation.goBack()} actions={[ { accessibilityLabel: "Refresh repository status", disabled: busy, icon: "arrow.clockwise", onPress: () => void gitActions.refreshSelectedThreadGitStatus(), }, ]} /> )} {content} ); }