1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { useCallback, useState } from "react";
3import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
4import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
5import { useSafeAreaInsets } from "react-native-safe-area-context";
6
7import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
8import { SymbolView } from "../../../components/AppSymbol";
9import { MaterialScreenContent } from "../../../components/MaterialScreenContent";
10import { NativeStackScreenOptions } from "../../../native/StackHeader";
11import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
12import { cn } from "../../../lib/cn";
13import { useEnvironmentQuery } from "../../../state/query";
14import { useThreadSelection } from "../../../state/use-thread-selection";
15import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
16import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
17import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
18import { vcsEnvironment } from "../../../state/vcs";
19import { SheetActionButton } from "./gitSheetComponents";
20
21type GitCommitSheetProps = StaticScreenProps<{
22 readonly environmentId: string;
23 readonly threadId: string;
24}>;
25
26export function GitCommitSheet(_props: GitCommitSheetProps) {
27 const navigation = useNavigation();
28 const insets = useSafeAreaInsets();
29 const { height: windowHeight } = useWindowDimensions();
30 const { selectedThread } = useThreadSelection();
31 const { selectedThreadCwd } = useSelectedThreadWorktree();
32 const gitState = useSelectedThreadGitState();
33 const gitActions = useSelectedThreadGitActions();
34 const { canWriteSourceControl, canChangeThreadBranch } = gitActions;
35
36 const gitStatus = useEnvironmentQuery(
37 selectedThread !== null && selectedThreadCwd !== null
38 ? vcsEnvironment.status({
39 environmentId: selectedThread.environmentId,
40 input: { cwd: selectedThreadCwd },
41 })
42 : null,
43 );
44
45 const busy = gitState.gitOperationLabel !== null;
46 const isDefaultRef = gitStatus.data?.isDefaultRef ?? false;
47 const allFiles = gitStatus.data?.workingTree?.files ?? [];
48
49 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
50 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
51 const [isEditingFiles, setIsEditingFiles] = useState(false);
52
53 const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path));
54 const allSelected = excludedFiles.size === 0;
55 const noneSelected = selectedFiles.length === 0;
56 const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0);
57 const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0);
58 const selectedFilePreview = selectedFiles.slice(0, 3);
59
60 const runCommitAction = useCallback(
61 async (featureBranch: boolean) => {
62 if (!canWriteSourceControl || (featureBranch && !canChangeThreadBranch)) return;
63 const commitMessage = dialogCommitMessage.trim();
64 navigation.goBack();
65 await gitActions.onRunSelectedThreadGitAction({
66 action: "commit",
67 featureBranch,
68 ...(commitMessage ? { commitMessage } : {}),
69 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
70 });
71 },
72 [
73 allSelected,
74 canWriteSourceControl,
75 canChangeThreadBranch,
76 dialogCommitMessage,
77 gitActions,
78 navigation,
79 selectedFiles,
80 ],
81 );
82
83 return (
84 <View
85 collapsable={false}
86 className="bg-sheet ios:flex-1"
87 style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined}
88 >
89 {Platform.OS === "android" ? (
90 <NativeStackScreenOptions
91 options={{
92 sheetCornerRadius: 28,
93 sheetAllowedDetents: "fitToContents",
94 }}
95 />
96 ) : null}
97 {Platform.OS === "android" ? (
98 <AndroidSheetHeader
99 title="Commit changes"
100 onBack={() => navigation.goBack()}
101 hideBottomBorder
102 />
103 ) : null}
104 <MaterialScreenContent fitToContents>
105 <ScrollView
106 nestedScrollEnabled
107 className="android:shrink android:grow-0 ios:flex-1"
108 keyboardShouldPersistTaps="handled"
109 showsVerticalScrollIndicator={false}
110 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
111 contentContainerClassName="android:gap-2 android:p-2 ios:gap-4 ios:px-5 ios:pt-2"
112 contentContainerStyle={
113 Platform.OS === "android"
114 ? { paddingBottom: Math.max(insets.bottom, 18) + 18 }
115 : undefined
116 }
117 >
118 <View className="gap-3 bg-card p-4 android:rounded-[20px] ios:rounded-[22px] ios:border ios:border-border">
119 <View className="android:gap-1 ios:flex-row ios:items-center ios:justify-between ios:gap-3">
120 <Text className="text-foreground-muted text-sm font-medium">Branch</Text>
121 <Text className="text-foreground text-base android:font-t3-medium ios:font-t3-bold">
122 {gitStatus.data?.refName ?? "(detached HEAD)"}
123 </Text>
124 </View>
125 {isDefaultRef ? (
126 <Text className="text-xs leading-normal text-warning-foreground">
127 Warning: this is the default branch.
128 </Text>
129 ) : null}
130 </View>
131
132 <View className="gap-3 bg-card p-4 android:rounded-[20px] ios:rounded-[22px] ios:border ios:border-border">
133 <View className="flex-row items-center justify-between gap-3">
134 <View className="gap-1">
135 <Text className="text-foreground text-base android:font-t3-medium ios:font-t3-bold">
136 Files
137 </Text>
138 <Text className="text-foreground-muted text-xs leading-normal">
139 {selectedFiles.length} selected · +{formatDiffCount(selectedInsertions)} / -
140 {formatDiffCount(selectedDeletions)}
141 </Text>
142 </View>
143 <View className="flex-row items-center gap-2">
144 {!allSelected && isEditingFiles ? (
145 <Pressable
146 className="rounded-full px-3 android:min-h-12 android:justify-center android:active:bg-subtle ios:bg-subtle ios:py-2"
147 onPress={() => setExcludedFiles(new Set())}
148 >
149 <Text className="android:text-primary-text android:text-sm android:font-t3-medium ios:text-foreground ios:text-2xs ios:font-t3-bold ios:uppercase">
150 Reset
151 </Text>
152 </Pressable>
153 ) : null}
154 <Pressable
155 className="rounded-full px-3 android:min-h-12 android:justify-center android:active:bg-subtle ios:bg-subtle ios:py-2"
156 onPress={() => setIsEditingFiles((current) => !current)}
157 >
158 <Text className="android:text-primary-text android:text-sm android:font-t3-medium ios:text-foreground ios:text-2xs ios:font-t3-bold ios:uppercase">
159 {isEditingFiles ? "Done" : "Edit"}
160 </Text>
161 </Pressable>
162 </View>
163 </View>
164
165 {allFiles.length === 0 ? (
166 <Text className="text-foreground-secondary text-sm leading-normal">
167 No changed files are available to commit.
168 </Text>
169 ) : !isEditingFiles ? (
170 <View className="gap-2">
171 {selectedFilePreview.map((file) => (
172 <View key={file.path} className="flex-row items-center justify-between gap-3">
173 <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}>
174 {file.path}
175 </Text>
176 <Text className="text-xs font-t3-bold text-adaptive-emerald-700-300">
177 +{formatDiffCount(file.insertions)}
178 </Text>
179 <Text className="text-xs font-t3-bold text-adaptive-rose-700-300">
180 -{formatDiffCount(file.deletions)}
181 </Text>
182 </View>
183 ))}
184 {selectedFiles.length > selectedFilePreview.length ? (
185 <Text className="text-foreground-muted text-xs leading-snug">
186 +{selectedFiles.length - selectedFilePreview.length} more files
187 </Text>
188 ) : null}
189 </View>
190 ) : (
191 <View className="gap-2">
192 {allFiles.map((file) => {
193 const included = !excludedFiles.has(file.path);
194 return (
195 <Pressable
196 key={file.path}
197 className={cn(
198 "px-4 py-3 android:rounded-xl ios:rounded-[18px] ios:border",
199 included
200 ? "android:bg-subtle ios:border-border"
201 : "ios:border-border-subtle",
202 )}
203 accessibilityRole="checkbox"
204 accessibilityLabel={file.path}
205 accessibilityState={{ checked: included }}
206 onPress={() => {
207 setExcludedFiles((current) => {
208 const next = new Set(current);
209 if (next.has(file.path)) {
210 next.delete(file.path);
211 } else {
212 next.add(file.path);
213 }
214 return next;
215 });
216 }}
217 >
218 {Platform.OS !== "android" ? (
219 <View
220 className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`}
221 />
222 ) : null}
223 <View className="flex-row items-start justify-between gap-3">
224 {Platform.OS === "android" ? (
225 <View
226 className={cn(
227 "mt-0.5 size-5 items-center justify-center rounded-sm",
228 included ? "bg-primary" : "border border-input-border",
229 )}
230 >
231 {included ? (
232 <SymbolView
233 name="checkmark"
234 size={16}
235 tintColorClassName="accent-primary-foreground"
236 type="monochrome"
237 />
238 ) : null}
239 </View>
240 ) : null}
241 <View className="flex-1 gap-1">
242 <Text
243 selectable
244 className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`}
245 >
246 {file.path}
247 </Text>
248 {!included ? (
249 <Text className="text-foreground-muted text-2xs leading-normal">
250 Excluded from this commit
251 </Text>
252 ) : null}
253 </View>
254 <View className="items-end gap-1">
255 <Text className="text-xs font-t3-bold text-adaptive-emerald-700-300">
256 +{formatDiffCount(file.insertions)}
257 </Text>
258 <Text className="text-xs font-t3-bold text-adaptive-rose-700-300">
259 -{formatDiffCount(file.deletions)}
260 </Text>
261 </View>
262 </View>
263 </Pressable>
264 );
265 })}
266 </View>
267 )}
268 </View>
269
270 <View className="android:gap-3 android:rounded-[20px] android:bg-card android:p-4 ios:gap-2">
271 <Text className="text-foreground android:text-base android:font-t3-medium ios:text-sm ios:font-t3-bold">
272 Commit message
273 </Text>
274 <TextInput
275 multiline
276 accessibilityLabel="Commit message"
277 value={dialogCommitMessage}
278 onChangeText={setDialogCommitMessage}
279 placeholder="Leave empty to auto-generate"
280 textAlignVertical="top"
281 className="min-h-[128px] px-4 py-3.5 android:rounded-xl android:bg-sheet-solid ios:rounded-[20px]"
282 />
283 </View>
284
285 <View className="android:gap-2 ios:flex-row ios:gap-3">
286 <View className="ios:flex-1">
287 <SheetActionButton
288 icon="arrow.branch"
289 label="Commit on new branch"
290 disabled={!canChangeThreadBranch || noneSelected || busy}
291 onPress={() => void runCommitAction(true)}
292 />
293 </View>
294 <View className="ios:flex-1">
295 <SheetActionButton
296 icon="checkmark.circle"
297 label="Commit"
298 tone="primary"
299 disabled={!canWriteSourceControl || noneSelected || busy}
300 onPress={() => void runCommitAction(false)}
301 />
302 </View>
303 </View>
304 </ScrollView>
305 </MaterialScreenContent>
306 </View>
307 );
308}