import {
type GitActionRequestInput,
buildMenuItems,
getGitActionDisabledReason,
requiresDefaultBranchConfirmation,
} from "@t3tools/client-runtime/state/vcs";
import {
resolveThreadPullRequestChains,
threadPullRequestKeyOf,
} from "@t3tools/shared/threadPullRequests";
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 { useUniwindTheme } from "../../../lib/useUniwindTheme";
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, selectedEnvironmentRuntime } = useThreadSelection();
const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
const supportsLinkedPrSnapshots =
selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.threadPullRequests === true;
const linkedPrChains = useMemo(
() =>
resolveThreadPullRequestChains(
supportsLinkedPrSnapshots ? (selectedThread?.pullRequests ?? []) : [],
),
[selectedThread?.pullRequests, supportsLinkedPrSnapshots],
);
const gitState = useSelectedThreadGitState();
const gitActions = useSelectedThreadGitActions();
const theme = useUniwindTheme();
const foregroundColor = theme["--color-foreground"];
const sheetColor = theme["--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),
})
}
/>
{linkedPrChains.length > 0 ? (
Linked pull requests
{linkedPrChains.map((chain) => (
{chain.layers.length > 1 ? (
{chain.kind === "native" ? "Stack" : "Branch stack"} · {chain.layers.length} PRs
· bottom to top
) : null}
{chain.layers.map((link, index) => (
{index > 0 ? : null}
{
void tryOpenExternalUrl(link.url, "pull-request").then((opened) => {
if (!opened)
Alert.alert("Unable to open PR", "The pull request could not be opened.");
});
}}
/>
))}
))}
) : null}
{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}
);
}