1import { useOpenPullRequest } from "../../../native/useOpenPullRequest";
2import {
3 type GitActionRequestInput,
4 buildMenuItems,
5 getGitActionDisabledReason,
6 requiresDefaultBranchConfirmation,
7} from "@t3tools/client-runtime/state/vcs";
8import {
9 resolveThreadPullRequestChains,
10 threadPullRequestKeyOf,
11} from "@t3tools/shared/threadPullRequests";
12import { EnvironmentId, ThreadId } from "@t3tools/contracts";
13import {
14 CommonActions,
15 StackActions,
16 useNavigation,
17 type StaticScreenProps,
18} from "@react-navigation/native";
19import { SymbolView } from "../../../components/AppSymbol";
20import { useCallback, useEffect, useMemo, useState } from "react";
21import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
22
23import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
24import { useSafeAreaInsets } from "react-native-safe-area-context";
25import { NativeSheetContent } from "../../../native/NativeSheetContent";
26import { useUniwindTheme } from "../../../lib/useUniwindTheme";
27import {
28 AndroidHeaderIconButton,
29 AndroidSheetHeader,
30} from "../../../components/AndroidScreenHeader";
31import { AndroidAnchoredMenu } from "../../../components/AndroidAnchoredMenu";
32import { MaterialScreenContent } from "../../../components/MaterialScreenContent";
33import { useAdaptiveWorkspaceLayout } from "../../layout/AdaptiveWorkspaceLayout";
34import { AppText as Text } from "../../../components/AppText";
35import {
36 NativeStackScreenOptions,
37 nativeHeaderScrollEdgeEffects,
38} from "../../../native/StackHeader";
39import { useEnvironmentQuery } from "../../../state/query";
40import { useThreadSelection } from "../../../state/use-thread-selection";
41import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
42import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
43import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
44import { vcsEnvironment } from "../../../state/vcs";
45import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation";
46import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
47
48const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
49
50type GitOverviewSheetProps = StaticScreenProps<{
51 readonly environmentId: string;
52 readonly threadId: string;
53}> & {
54 readonly headerInset?: number;
55 readonly presentation?: "sheet" | "inspector";
56};
57
58export function GitOverviewSheet(props: GitOverviewSheetProps) {
59 const navigation = useNavigation();
60 const { environmentId, threadId } = props.route.params;
61 // A hand-typed deep link can carry a blank ID, which the branded IDs reject.
62 const isBlankLink = environmentId.trim().length === 0 || threadId.trim().length === 0;
63 useEffect(() => {
64 if (isBlankLink) navigation.goBack();
65 }, [isBlankLink, navigation]);
66 if (isBlankLink) return null;
67 return <GitOverviewSheetContent {...props} />;
68}
69
70function GitOverviewSheetContent(props: GitOverviewSheetProps) {
71 const { layout } = useAdaptiveWorkspaceLayout();
72 const navigation = useNavigation();
73 const insets = useSafeAreaInsets();
74 const presentation = props.presentation ?? "sheet";
75 const openPullRequest = useOpenPullRequest({ replace: presentation === "sheet" });
76 const isInspector = presentation === "inspector";
77 const environmentId = EnvironmentId.make(props.route.params.environmentId);
78 const threadId = ThreadId.make(props.route.params.threadId);
79 const { selectedThread, selectedEnvironmentRuntime } = useThreadSelection();
80 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
81 const supportsLinkedPrSnapshots =
82 selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.threadPullRequests === true;
83 const linkedPrChains = useMemo(
84 () =>
85 resolveThreadPullRequestChains(
86 supportsLinkedPrSnapshots ? (selectedThread?.pullRequests ?? []) : [],
87 ),
88 [selectedThread?.pullRequests, supportsLinkedPrSnapshots],
89 );
90 const gitState = useSelectedThreadGitState();
91 const gitActions = useSelectedThreadGitActions();
92 const { canWriteSourceControl, canChangeThreadBranch } = gitActions;
93 const theme = useUniwindTheme();
94 const foregroundColor = theme["--color-foreground"];
95 const sheetColor = theme["--color-sheet"];
96
97 const gitStatus = useEnvironmentQuery(
98 selectedThread !== null && selectedThreadCwd !== null
99 ? vcsEnvironment.status({
100 environmentId: selectedThread.environmentId,
101 input: { cwd: selectedThreadCwd },
102 })
103 : null,
104 );
105
106 const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
107 const currentStatusSummary = statusSummary(gitStatus.data);
108 const currentWorktreePath = selectedThreadWorktreePath;
109 const gitOperationLabel = gitState.gitOperationLabel;
110 const busy = gitOperationLabel !== null;
111 const isRepo = gitStatus.data?.isRepo ?? true;
112 const hasPrimaryRemote = gitStatus.data?.hasPrimaryRemote ?? false;
113 const isDefaultRef = gitStatus.data?.isDefaultRef ?? false;
114
115 const menuItems = useMemo(
116 () => (isRepo ? buildMenuItems(gitStatus.data, busy, hasPrimaryRemote) : []),
117 [busy, gitStatus.data, hasPrimaryRemote, isRepo],
118 );
119
120 const sheetMenuItems = useMemo(
121 () =>
122 menuItems.map((item) => ({
123 item: {
124 ...item,
125 disabled: item.disabled || (!canWriteSourceControl && item.kind !== "open_pr"),
126 },
127 disabledReason:
128 !canWriteSourceControl && item.kind !== "open_pr"
129 ? "This connection cannot change source control."
130 : getGitActionDisabledReason({
131 item,
132 gitStatus: gitStatus.data,
133 isBusy: busy,
134 hasOriginRemote: hasPrimaryRemote,
135 }),
136 })),
137 [busy, canWriteSourceControl, gitStatus.data, hasPrimaryRemote, menuItems],
138 );
139
140 const openExistingPr = useCallback(async () => {
141 const prUrl = gitStatus.data?.pr?.state === "open" ? gitStatus.data.pr.url : null;
142 if (!prUrl) {
143 Alert.alert("No open PR", "This branch does not have an open pull request.");
144 return;
145 }
146 if (!(await openPullRequest(prUrl))) {
147 Alert.alert("Unable to open PR", "The pull request could not be opened.");
148 }
149 }, [gitStatus.data, openPullRequest]);
150
151 const runActionWithPrompt = useCallback(
152 async (input: GitActionRequestInput) => {
153 if (!canWriteSourceControl) return;
154 const confirmableAction =
155 input.action === "push" ||
156 input.action === "create_pr" ||
157 input.action === "commit_push" ||
158 input.action === "commit_push_pr"
159 ? input.action
160 : null;
161 const branchName = gitStatus.data?.refName;
162 if (
163 branchName &&
164 confirmableAction &&
165 !input.featureBranch &&
166 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
167 ) {
168 navigation.navigate("GitConfirm", {
169 environmentId: String(environmentId),
170 threadId: String(threadId),
171 confirmAction: confirmableAction,
172 branchName,
173 includesCommit: String(
174 input.action === "commit_push" || input.action === "commit_push_pr",
175 ),
176 });
177 return;
178 }
179
180 if (!isInspector) {
181 navigation.goBack();
182 }
183 await gitActions.onRunSelectedThreadGitAction(input);
184 },
185 [
186 canWriteSourceControl,
187 environmentId,
188 gitActions,
189 gitStatus.data,
190 isDefaultRef,
191 isInspector,
192 navigation,
193 threadId,
194 ],
195 );
196
197 const onPressMenuItem = useCallback(
198 async (item: (typeof menuItems)[number]) => {
199 if (item.disabled) return;
200 if (item.kind === "open_pr") {
201 await openExistingPr();
202 return;
203 }
204 if (!canWriteSourceControl) return;
205 if (item.dialogAction === "commit") {
206 navigation.navigate("GitCommit", {
207 environmentId: String(environmentId),
208 threadId: String(threadId),
209 });
210 return;
211 }
212 if (item.dialogAction === "push") {
213 await runActionWithPrompt({ action: "push" });
214 return;
215 }
216 if (item.dialogAction === "create_pr") {
217 await runActionWithPrompt({ action: "create_pr" });
218 }
219 },
220 [
221 canWriteSourceControl,
222 environmentId,
223 openExistingPr,
224 navigation,
225 runActionWithPrompt,
226 threadId,
227 ],
228 );
229
230 // Status facts live on the relevant rows instead of crowding the header
231 // subtitle: files changed → Commit, ahead → Push, PR → View PR, behind → Pull.
232 const rowStatusDetail = useCallback(
233 (item: (typeof menuItems)[number]): string | undefined => {
234 const status = gitStatus.data;
235 if (status == null) {
236 return undefined;
237 }
238 if (item.dialogAction === "commit" && status.hasWorkingTreeChanges) {
239 const fileCount = status.workingTree?.files.length ?? 0;
240 return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`;
241 }
242 if (item.dialogAction === "push" && (status.aheadCount ?? 0) > 0) {
243 const ahead = status.aheadCount ?? 0;
244 return `${ahead} commit${ahead === 1 ? "" : "s"} ahead`;
245 }
246 if (item.kind === "open_pr" && status.pr?.number != null) {
247 return `PR #${status.pr.number} ${status.pr.state ?? "open"}`;
248 }
249 return undefined;
250 },
251 [gitStatus.data, menuItems],
252 );
253
254 const behindCount = gitStatus.data?.behindCount ?? 0;
255
256 // Deterministic refresh state. Tying RefreshControl to the query's
257 // isPending flag left the spinner stuck (the status query reports pending
258 // during quiet background refreshes too).
259 const [isRefreshing, setIsRefreshing] = useState(false);
260 const handleRefresh = useCallback(async () => {
261 setIsRefreshing(true);
262 try {
263 await gitActions.refreshSelectedThreadGitStatus();
264 } finally {
265 setIsRefreshing(false);
266 }
267 }, [gitActions]);
268
269 const content = (
270 <ScrollView
271 nestedScrollEnabled
272 alwaysBounceVertical
273 className="flex-1 android:bg-sheet-solid ios:bg-screen"
274 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
275 showsVerticalScrollIndicator={false}
276 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
277 contentContainerStyle={{
278 paddingHorizontal: Platform.OS === "android" ? 8 : isInspector ? 12 : 20,
279 paddingTop: 8,
280 gap: Platform.OS === "android" ? 8 : 14,
281 }}
282 refreshControl={
283 Platform.OS === "ios" || isInspector ? (
284 <RefreshControl refreshing={isRefreshing} onRefresh={() => void handleRefresh()} />
285 ) : undefined
286 }
287 >
288 <View
289 className={`overflow-hidden bg-card android:rounded-[20px] ios:border ios:border-border ${
290 isInspector ? "ios:rounded-2xl ios:px-3 ios:py-1" : "ios:rounded-[22px] ios:px-4 ios:py-1"
291 }`}
292 >
293 {sheetMenuItems.map(({ item, disabledReason }, index) => (
294 <View key={`${item.id}-${item.label}`}>
295 {index > 0 && Platform.OS !== "android" ? (
296 <View className="ml-12 h-px bg-border" />
297 ) : null}
298 <SheetListRow
299 icon={menuItemIconName(item.icon)}
300 title={item.label}
301 subtitle={disabledReason ?? rowStatusDetail(item)}
302 disabled={item.disabled}
303 onPress={() => void onPressMenuItem(item)}
304 />
305 </View>
306 ))}
307 {behindCount > 0 ? (
308 <>
309 {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
310 <SheetListRow
311 icon="arrow.down.circle"
312 title="Pull latest"
313 subtitle={
314 canWriteSourceControl
315 ? `${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`
316 : "This connection cannot change source control."
317 }
318 disabled={!canWriteSourceControl || busy || !isRepo}
319 onPress={() => void gitActions.onPullSelectedThreadBranch()}
320 />
321 </>
322 ) : null}
323 {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
324 <SheetListRow
325 icon="text.bubble"
326 title="Review changes"
327 subtitle="Inspect changes, uncommitted edits, and turn diffs"
328 disabled={busy || !isRepo}
329 onPress={() => {
330 const params = { environmentId, threadId };
331 navigation.dispatch(
332 resolveGitOverviewReviewNavigationAction(presentation) === "replace"
333 ? StackActions.replace("ThreadReview", params)
334 : CommonActions.navigate("ThreadReview", params),
335 );
336 }}
337 />
338 {Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
339 <SheetListRow
340 icon="point.topleft.down.curvedto.point.bottomright.up"
341 title="Branches & worktrees"
342 subtitle={
343 canChangeThreadBranch
344 ? "Switch branch, create branch, or move to a worktree"
345 : "View branches and worktrees"
346 }
347 disabled={busy || !isRepo}
348 onPress={() =>
349 navigation.navigate("GitBranches", {
350 environmentId: String(environmentId),
351 threadId: String(threadId),
352 })
353 }
354 />
355 </View>
356
357 {linkedPrChains.length > 0 ? (
358 <View className="gap-2">
359 <Text className="px-1 text-xs font-t3-bold text-foreground-muted">
360 Linked pull requests
361 </Text>
362 {linkedPrChains.map((chain) => (
363 <View
364 key={threadPullRequestKeyOf(chain.layers[0]!)}
365 className="overflow-hidden bg-card android:rounded-[20px] ios:rounded-2xl ios:border ios:border-border ios:px-3 ios:py-1"
366 >
367 {chain.layers.length > 1 ? (
368 <View className="flex-row items-center gap-2 px-1 pt-2 pb-1">
369 <SymbolView
370 name="square.3.layers.3d"
371 size={14}
372 tintColorClassName="accent-foreground-muted"
373 />
374 <Text className="text-xs text-foreground-muted">
375 {chain.kind === "native" ? "Stack" : "Branch stack"} · {chain.layers.length} PRs
376 · bottom to top
377 </Text>
378 </View>
379 ) : null}
380 {chain.layers.map((link, index) => (
381 <View key={threadPullRequestKeyOf(link)}>
382 {index > 0 && Platform.OS !== "android" ? (
383 <View className="ml-12 h-px bg-border" />
384 ) : null}
385 <SheetListRow
386 icon="arrow.triangle.pull"
387 title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`}
388 subtitle={`${link.repository} · ${link.snapshot === null ? "Status pending" : link.snapshot.isDraft && link.snapshot.state === "open" ? "Draft" : link.snapshot.state}${link.watch === undefined ? "" : " · Watching"}`}
389 onPress={() => {
390 void openPullRequest(link.url).then((opened) => {
391 if (!opened)
392 Alert.alert("Unable to open PR", "The pull request could not be opened.");
393 });
394 }}
395 />
396 </View>
397 ))}
398 </View>
399 ))}
400 </View>
401 ) : null}
402
403 {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
404 </ScrollView>
405 );
406
407 if (isInspector && Platform.OS === "ios") {
408 return (
409 <View collapsable={false} className="flex-1 border-l border-border bg-sheet">
410 <ScreenStack style={{ flex: 1 }}>
411 <Screen
412 activityState={2}
413 enabled
414 isNativeStack
415 screenId="thread-git-inspector-native"
416 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
417 style={{ backgroundColor: sheetColor, flex: 1 }}
418 >
419 <NativeSheetContent>{content}</NativeSheetContent>
420 <ScreenStackHeaderConfig
421 backgroundColor="rgba(0,0,0,0)"
422 color={foregroundColor}
423 hideBackButton
424 hideShadow={false}
425 navigationItemStyle="editor"
426 title={currentBranchLabel}
427 titleColor={foregroundColor}
428 titleFontSize={17}
429 titleFontWeight="700"
430 translucent
431 />
432 </Screen>
433 </ScreenStack>
434 </View>
435 );
436 }
437
438 if (Platform.OS === "ios") {
439 // Compact form sheet: a plain screen presented as formSheet never renders a
440 // stack header, so — like the Settings sheet — the header must come from a
441 // nested native stack INSIDE the sheet. This reuses the exact structure of the
442 // inspector branch below: branch as the title, status summary as the native
443 // subtitle, and content that owns pull-to-refresh.
444 return (
445 <View collapsable={false} className="flex-1 bg-sheet">
446 <ScreenStack style={{ flex: 1 }}>
447 <Screen
448 activityState={2}
449 enabled
450 isNativeStack
451 screenId="thread-git-sheet-native"
452 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
453 style={{ backgroundColor: sheetColor, flex: 1 }}
454 >
455 <NativeSheetContent>{content}</NativeSheetContent>
456 <ScreenStackHeaderConfig
457 backgroundColor="rgba(0,0,0,0)"
458 color={foregroundColor}
459 hideBackButton
460 hideShadow={false}
461 navigationItemStyle="editor"
462 title={currentBranchLabel}
463 titleColor={foregroundColor}
464 titleFontSize={18}
465 titleFontWeight="800"
466 translucent
467 />
468 </Screen>
469 </ScreenStack>
470 </View>
471 );
472 }
473
474 const refreshMenu = (
475 <AndroidAnchoredMenu
476 title="Repository options"
477 actions={[
478 {
479 id: "refresh",
480 title: "Refresh repository status",
481 attributes: { disabled: busy || isRefreshing },
482 },
483 ]}
484 onPressAction={({ nativeEvent }) => {
485 if (nativeEvent.event === "refresh") void handleRefresh();
486 }}
487 >
488 {(open) => (
489 <AndroidHeaderIconButton
490 accessibilityLabel="Repository options"
491 icon="ellipsis"
492 onPress={open}
493 />
494 )}
495 </AndroidAnchoredMenu>
496 );
497
498 return (
499 <View
500 collapsable={false}
501 className={
502 Platform.OS === "android"
503 ? "flex-1 bg-header"
504 : isInspector
505 ? "flex-1 border-l border-border bg-sheet"
506 : "flex-1 bg-sheet"
507 }
508 >
509 {!isInspector ? (
510 <NativeStackScreenOptions
511 options={{ sheetCornerRadius: Platform.OS === "android" ? 28 : undefined }}
512 />
513 ) : null}
514 {isInspector ? (
515 <View
516 style={{
517 minHeight: props.headerInset ?? 0,
518 paddingTop: props.headerInset ?? 0,
519 }}
520 />
521 ) : null}
522
523 {isInspector ? (
524 <View className="gap-1 px-4 pb-4 pt-3 android:bg-header ios:border-b ios:border-border">
525 {Platform.OS === "android" ? (
526 <View className="absolute right-3 top-4 z-[1]">{refreshMenu}</View>
527 ) : (
528 <Pressable
529 className={
530 busy
531 ? "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle opacity-[0.45]"
532 : "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle"
533 }
534 disabled={busy}
535 onPress={() => void gitActions.refreshSelectedThreadGitStatus()}
536 >
537 <SymbolView
538 name="arrow.clockwise"
539 size={16}
540 tintColorClassName="accent-icon"
541 type="monochrome"
542 weight="medium"
543 />
544 </Pressable>
545 )}
546 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">
547 Repository
548 </Text>
549 <Text className="pr-10 text-xl font-t3-bold">{currentBranchLabel}</Text>
550 <Text className="text-foreground-secondary text-sm font-medium leading-normal">
551 {currentStatusSummary}
552 </Text>
553 </View>
554 ) : (
555 <AndroidSheetHeader
556 title={currentBranchLabel}
557 hideBottomBorder={Platform.OS === "android"}
558 subtitle={currentStatusSummary}
559 onBack={() => navigation.goBack()}
560 trailing={Platform.OS === "android" ? refreshMenu : undefined}
561 actions={
562 Platform.OS === "android"
563 ? undefined
564 : [
565 {
566 accessibilityLabel: "Refresh repository status",
567 disabled: busy,
568 icon: "arrow.clockwise",
569 onPress: () => void gitActions.refreshSelectedThreadGitStatus(),
570 },
571 ]
572 }
573 />
574 )}
575
576 <MaterialScreenContent insetHorizontal={layout.usesSplitView}>
577 {content}
578 </MaterialScreenContent>
579 </View>
580 );
581}