| 1 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | import { useCallback, useState } from "react"; |
| 3 | import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; |
| 4 | import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | |
| 7 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; |
| 8 | import { SymbolView } from "../../../components/AppSymbol"; |
| 9 | import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; |
| 10 | import { NativeStackScreenOptions } from "../../../native/StackHeader"; |
| 11 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 12 | import { cn } from "../../../lib/cn"; |
| 13 | import { useEnvironmentQuery } from "../../../state/query"; |
| 14 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| 15 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; |
| 16 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; |
| 17 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; |
| 18 | import { vcsEnvironment } from "../../../state/vcs"; |
| 19 | import { SheetActionButton } from "./gitSheetComponents"; |
| 20 | |
| 21 | type GitCommitSheetProps = StaticScreenProps<{ |
| 22 | readonly environmentId: string; |
| 23 | readonly threadId: string; |
| 24 | }>; |
| 25 | |
| 26 | export 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 | } |