Open History to see the last commit
1import * as Dialog from "../../../components/ui/Dialog";
2import { useOpenPullRequest } from "../../../native/useOpenPullRequest";
3import {
4 type GitActionRequestInput,
5 buildMenuItems,
6 getGitActionDisabledReason,
7 requiresDefaultBranchConfirmation,
8} from "@t3tools/client-runtime/state/vcs";
9import { EnvironmentId, ThreadId } from "@t3tools/contracts";
10import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
11import {
12 CommonActions,
13 StackActions,
14 useNavigation,
15 type StaticScreenProps,
16} from "@react-navigation/native";
17import { use, useEffect, useState } from "react";
18import { RefreshControl } from "../../../components/ui/RefreshControl";
19import { NativeScrollView } from "../../../native/NativeScrollViewMarker";
20
21import { useSafeAreaInsets } from "react-native-safe-area-context";
22import { AppBar } from "../../../components/ui/AppBar";
23import { ListRow } from "../../../components/ui/ListRow";
24import { ListSection } from "../../../components/ui/ListSection";
25import { groupedListInsets } from "../../../components/ui/listMetrics";
26import { ScreenContent } from "../../../components/ui/ScreenContent";
27import { SheetPane } from "../../../components/ui/SheetPane";
28import { InspectorSheetContext } from "../../layout/workspace-inspector-sheet";
29import { AppText as Text } from "../../../components/ui/AppText";
30import { useEnvironmentQuery } from "../../../state/query";
31import { useThreadSelection } from "../../../state/use-thread-selection";
32import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
33import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
34import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
35import { vcsEnvironment } from "../../../state/vcs";
36import { menuItemIconName, statusSummary } from "./gitSheetComponents";
37import {
38 LinkedPullRequestRow,
39 linkedPullRequestKey,
40 useLinkedPullRequestLines,
41} from "../LinkedPullRequestList";
42import { useLinkedPullRequestActions } from "../useLinkedPullRequestActions";
43
44type GitOverviewSheetProps = StaticScreenProps<{
45 readonly environmentId: string;
46 readonly threadId: string;
47}> & {
48 readonly presentation?: "sheet" | "inspector";
49};
50
51export function GitOverviewSheet(props: GitOverviewSheetProps) {
52 const navigation = useNavigation();
53 const { environmentId, threadId } = props.route.params;
54 const isBlankLink = environmentId.trim().length === 0 || threadId.trim().length === 0;
55 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pops the navigator when the route link names a blank environment or thread
56 useEffect(() => {
57 if (isBlankLink) navigation.goBack();
58 }, [isBlankLink, navigation]);
59 if (isBlankLink) return null;
60 return <GitOverviewSheetContent {...props} />;
61}
62
63function GitOverviewSheetContent(props: GitOverviewSheetProps) {
64 const navigation = useNavigation();
65 const insets = useSafeAreaInsets();
66 const presentation = props.presentation ?? "sheet";
67 const isInspector = presentation === "inspector";
68 const closeInspectorSheet = use(InspectorSheetContext);
69 const presentRoute = (present: () => void) =>
70 closeInspectorSheet ? closeInspectorSheet(present) : present();
71 const environmentId = EnvironmentId.make(props.route.params.environmentId);
72 const threadId = ThreadId.make(props.route.params.threadId);
73 const { selectedThread, selectedThreadProject, selectedEnvironmentRuntime } =
74 useThreadSelection();
75 const openPullRequest = useOpenPullRequest({
76 replace: presentation === "sheet",
77 context: selectedThread ?? undefined,
78 });
79 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
80 const linkedPrLines = useLinkedPullRequestLines(selectedThread, selectedEnvironmentRuntime);
81 const linkedPrActions = useLinkedPullRequestActions({
82 thread: selectedThread,
83 project: selectedThreadProject,
84 runtime: selectedEnvironmentRuntime,
85 });
86 const gitState = useSelectedThreadGitState();
87 const gitActions = useSelectedThreadGitActions();
88 const { canWriteSourceControl, canChangeThreadBranch } = gitActions;
89
90 const gitStatus = useEnvironmentQuery(
91 selectedThread !== null && selectedThreadCwd !== null
92 ? vcsEnvironment.status({
93 environmentId: selectedThread.environmentId,
94 input: { cwd: selectedThreadCwd },
95 })
96 : null,
97 );
98
99 const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
100 const currentStatusSummary = statusSummary(gitStatus.data);
101 const currentWorktreePath = selectedThreadWorktreePath;
102 const gitOperationLabel = gitState.gitOperationLabel;
103 const busy = gitOperationLabel !== null;
104 const isRepo = gitStatus.data?.isRepo ?? true;
105 const hasPrimaryRemote = gitStatus.data?.hasPrimaryRemote ?? false;
106 const isDefaultRef = gitStatus.data?.isDefaultRef ?? false;
107
108 const menuItems = isRepo ? buildMenuItems(gitStatus.data, busy, hasPrimaryRemote) : [];
109
110 const sheetMenuItems = menuItems.map((item) => ({
111 item: {
112 ...item,
113 disabled: item.disabled || (!canWriteSourceControl && item.kind !== "open_pr"),
114 },
115 disabledReason:
116 !canWriteSourceControl && item.kind !== "open_pr"
117 ? "This connection cannot change source control."
118 : getGitActionDisabledReason({
119 item,
120 gitStatus: gitStatus.data,
121 isBusy: busy,
122 hasOriginRemote: hasPrimaryRemote,
123 }),
124 }));
125
126 const { shortLabel, singular } = sourceControlClients.get(
127 gitStatus.data?.sourceControlProvider?.kind,
128 ).changeRequest;
129
130 const openExistingPr = async () => {
131 const prUrl = gitStatus.data?.pr?.state === "open" ? gitStatus.data.pr.url : null;
132 if (!prUrl) {
133 Dialog.alert(`No open ${shortLabel}`, `This branch does not have an open ${singular}.`);
134 return;
135 }
136 if (!(await openPullRequest(prUrl))) {
137 Dialog.alert(`Unable to open ${shortLabel}`, `The ${singular} could not be opened.`);
138 }
139 };
140
141 const runActionWithPrompt = async (input: GitActionRequestInput) => {
142 if (!canWriteSourceControl) return;
143 const confirmableAction =
144 input.action === "push" ||
145 input.action === "create_pr" ||
146 input.action === "commit_push" ||
147 input.action === "commit_push_pr"
148 ? input.action
149 : null;
150 const branchName = gitStatus.data?.refName;
151 if (
152 branchName &&
153 confirmableAction &&
154 !input.featureBranch &&
155 requiresDefaultBranchConfirmation(input.action, isDefaultRef)
156 ) {
157 presentRoute(() =>
158 navigation.navigate("GitConfirm", {
159 environmentId: String(environmentId),
160 threadId: String(threadId),
161 confirmAction: confirmableAction,
162 branchName,
163 includesCommit: String(
164 input.action === "commit_push" || input.action === "commit_push_pr",
165 ),
166 }),
167 );
168 return;
169 }
170
171 // Close a sheet so the thread shows the action's progress.
172 if (!isInspector) navigation.goBack();
173 else closeInspectorSheet?.();
174 await gitActions.onRunSelectedThreadGitAction(input);
175 };
176
177 const onPressMenuItem = async (item: (typeof menuItems)[number]) => {
178 if (item.disabled) return;
179 if (item.kind === "open_pr") {
180 await openExistingPr();
181 return;
182 }
183 if (!canWriteSourceControl) return;
184 if (item.dialogAction === "commit") {
185 presentRoute(() =>
186 navigation.navigate("GitCommit", {
187 environmentId: String(environmentId),
188 threadId: String(threadId),
189 }),
190 );
191 return;
192 }
193 if (item.dialogAction === "push") {
194 await runActionWithPrompt({ action: "push" });
195 return;
196 }
197 if (item.dialogAction === "create_pr") {
198 await runActionWithPrompt({ action: "create_pr" });
199 }
200 };
201
202 const rowStatusDetail = (item: (typeof menuItems)[number]): string | undefined => {
203 const status = gitStatus.data;
204 if (status == null) {
205 return undefined;
206 }
207 if (item.dialogAction === "commit" && status.hasWorkingTreeChanges) {
208 const fileCount = status.workingTree?.files.length ?? 0;
209 return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`;
210 }
211 if (item.dialogAction === "push" && (status.aheadCount ?? 0) > 0) {
212 const ahead = status.aheadCount ?? 0;
213 return `${ahead} commit${ahead === 1 ? "" : "s"} ahead`;
214 }
215 if (item.kind === "open_pr" && status.pr?.number != null) {
216 return `${shortLabel} #${status.pr.number} ${status.pr.state ?? "open"}`;
217 }
218 return undefined;
219 };
220
221 const behindCount = gitStatus.data?.behindCount ?? 0;
222
223 // Background refreshes also report pending; using that query flag would stick the RefreshControl spinner.
224 const [isRefreshing, setIsRefreshing] = useState(false);
225 const handleRefresh = async () => {
226 setIsRefreshing(true);
227 await gitActions.refreshSelectedThreadGitStatus();
228 setIsRefreshing(false);
229 };
230
231 const content = (
232 <NativeScrollView
233 nestedScrollEnabled
234 alwaysBounceVertical
235 className="flex-1 bg-screen"
236 contentInsetAdjustmentBehavior="automatic"
237 showsVerticalScrollIndicator={false}
238 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
239 contentContainerStyle={{ ...groupedListInsets(isInspector), paddingTop: 8 }}
240 refreshControl={
241 <RefreshControl refreshing={isRefreshing} onRefresh={() => void handleRefresh()} />
242 }
243 >
244 <ListSection>
245 {sheetMenuItems.map(({ item, disabledReason }) => (
246 <ListRow
247 key={`${item.id}-${item.label}`}
248 icon={menuItemIconName(item.icon)}
249 label={item.label}
250 description={disabledReason ?? rowStatusDetail(item)}
251 disabled={item.disabled}
252 onPress={() => void onPressMenuItem(item)}
253 />
254 ))}
255 {behindCount > 0 ? (
256 <ListRow
257 icon="arrow.down.circle"
258 label="Pull latest"
259 description={
260 canWriteSourceControl
261 ? `${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`
262 : "This connection cannot change source control."
263 }
264 disabled={!canWriteSourceControl || busy || !isRepo}
265 onPress={() => void gitActions.onPullSelectedThreadBranch()}
266 />
267 ) : null}
268 <ListRow
269 icon="text.bubble"
270 label="Review changes"
271 description="Inspect changes, uncommitted edits, and turn diffs"
272 disabled={busy || !isRepo}
273 onPress={() => {
274 const params = { environmentId, threadId };
275 if (presentation === "sheet") {
276 navigation.dispatch(StackActions.replace("ThreadReview", params));
277 } else {
278 presentRoute(() =>
279 navigation.dispatch(CommonActions.navigate("ThreadReview", params)),
280 );
281 }
282 }}
283 />
284 <ListRow
285 icon="point.topleft.down.curvedto.point.bottomright.up"
286 label="Branches & worktrees"
287 description={
288 canChangeThreadBranch
289 ? "Switch branch, create branch, or move to a worktree"
290 : "View branches and worktrees"
291 }
292 disabled={busy || !isRepo}
293 onPress={() =>
294 presentRoute(() =>
295 navigation.navigate("GitBranches", {
296 environmentId: String(environmentId),
297 threadId: String(threadId),
298 }),
299 )
300 }
301 />
302 {linkedPrActions.canLink ? (
303 <ListRow
304 icon="link"
305 label={`Link ${linkedPrActions.terminology.singular}`}
306 description={`Attach a ${linkedPrActions.terminology.singular} by URL or number`}
307 onPress={linkedPrActions.promptLink}
308 />
309 ) : null}
310 </ListSection>
311
312 {linkedPrLines.length > 0 ? (
313 <ListSection title={`Linked ${linkedPrActions.terminology.plural}`}>
314 {linkedPrLines.map((line, index) => (
315 <LinkedPullRequestRow
316 key={linkedPullRequestKey(line)}
317 line={line}
318 separator={index < linkedPrLines.length - 1}
319 surfaceClassName="text-grouped-card"
320 openPullRequest={openPullRequest}
321 actions={linkedPrActions}
322 />
323 ))}
324 </ListSection>
325 ) : null}
326
327 {currentWorktreePath ? (
328 <ListSection title="Worktree">
329 <Text selectable textRole="footnote" className="p-4 text-foreground-muted">
330 {currentWorktreePath}
331 </Text>
332 </ListSection>
333 ) : null}
334 </NativeScrollView>
335 );
336
337 const refresh = {
338 accessibilityLabel: "Refresh repository status",
339 disabled: busy || isRefreshing,
340 onPress: () => void handleRefresh(),
341 };
342
343 return (
344 <SheetPane surface={isInspector ? "screen" : "sheet"}>
345 <AppBar
346 title={currentBranchLabel}
347 subtitle={currentStatusSummary}
348 hideBottomBorder={!isInspector}
349 onClose={isInspector ? undefined : () => navigation.goBack()}
350 refresh={refresh}
351 />
352 <ScreenContent insetHorizontal={isInspector ? true : undefined}>{content}</ScreenContent>
353 </SheetPane>
354 );
355}