| 1 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { useState } from "react"; |
| 4 | import { Platform, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | |
| 7 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; |
| 8 | import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; |
| 9 | import { NativeStackScreenOptions } from "../../../native/StackHeader"; |
| 10 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 11 | import { cn } from "../../../lib/cn"; |
| 12 | import { useEnvironmentQuery } from "../../../state/query"; |
| 13 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| 14 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; |
| 15 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; |
| 16 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; |
| 17 | import { vcsEnvironment } from "../../../state/vcs"; |
| 18 | import { SheetActionButton } from "./gitSheetComponents"; |
| 19 | |
| 20 | type GitBranchesSheetProps = StaticScreenProps<{ |
| 21 | readonly environmentId: string; |
| 22 | readonly threadId: string; |
| 23 | }>; |
| 24 | |
| 25 | export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 26 | const navigation = useNavigation(); |
| 27 | const insets = useSafeAreaInsets(); |
| 28 | const { height: windowHeight } = useWindowDimensions(); |
| 29 | const { selectedThread } = useThreadSelection(); |
| 30 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| 31 | const gitState = useSelectedThreadGitState(); |
| 32 | const gitActions = useSelectedThreadGitActions(); |
| 33 | const { canChangeThreadBranch } = gitActions; |
| 34 | |
| 35 | const gitStatus = useEnvironmentQuery( |
| 36 | selectedThread !== null && selectedThreadCwd !== null |
| 37 | ? vcsEnvironment.status({ |
| 38 | environmentId: selectedThread.environmentId, |
| 39 | input: { cwd: selectedThreadCwd }, |
| 40 | }) |
| 41 | : null, |
| 42 | ); |
| 43 | |
| 44 | const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; |
| 45 | const currentWorktreePath = selectedThreadWorktreePath; |
| 46 | const availableBranches = gitState.selectedThreadBranches; |
| 47 | const branchesLoading = gitState.selectedThreadBranchesLoading; |
| 48 | const busy = gitState.gitOperationLabel !== null; |
| 49 | |
| 50 | const [newBranchName, setNewBranchName] = useState(""); |
| 51 | const [worktreeBaseBranch, setWorktreeBaseBranch] = useState( |
| 52 | currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel, |
| 53 | ); |
| 54 | const [worktreeBranchName, setWorktreeBranchName] = useState(""); |
| 55 | |
| 56 | const disabledExistingBranchNames: Array<string> = []; |
| 57 | for (const branch of availableBranches) { |
| 58 | if (branch.worktreePath !== null && branch.worktreePath !== currentWorktreePath) { |
| 59 | disabledExistingBranchNames.push(branch.name); |
| 60 | } |
| 61 | } |
| 62 | const disabledExistingBranches = new Set(disabledExistingBranchNames); |
| 63 | |
| 64 | return ( |
| 65 | <View |
| 66 | collapsable={false} |
| 67 | className="bg-sheet ios:flex-1" |
| 68 | style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined} |
| 69 | > |
| 70 | {Platform.OS === "android" ? ( |
| 71 | <NativeStackScreenOptions |
| 72 | options={{ |
| 73 | sheetCornerRadius: 28, |
| 74 | sheetAllowedDetents: "fitToContents", |
| 75 | }} |
| 76 | /> |
| 77 | ) : null} |
| 78 | {Platform.OS === "android" ? ( |
| 79 | <AndroidSheetHeader |
| 80 | title="Branches & worktrees" |
| 81 | onBack={() => navigation.goBack()} |
| 82 | hideBottomBorder |
| 83 | /> |
| 84 | ) : null} |
| 85 | <MaterialScreenContent fitToContents> |
| 86 | <ScrollView |
| 87 | nestedScrollEnabled |
| 88 | className="android:shrink android:grow-0 ios:flex-1" |
| 89 | keyboardShouldPersistTaps="handled" |
| 90 | showsVerticalScrollIndicator={false} |
| 91 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} |
| 92 | contentContainerClassName="android:gap-2 android:p-2 ios:gap-4 ios:px-5 ios:pt-2" |
| 93 | contentContainerStyle={ |
| 94 | Platform.OS === "android" |
| 95 | ? { paddingBottom: Math.max(insets.bottom, 18) + 18 } |
| 96 | : undefined |
| 97 | } |
| 98 | > |
| 99 | <View className="bg-card android:gap-3 android:rounded-[20px] android:p-4 ios:gap-2 ios:rounded-[18px] ios:border ios:border-border ios:px-4 ios:py-4"> |
| 100 | <Text className="android:text-foreground android:text-base android:font-t3-medium ios:text-foreground-secondary ios:text-2xs ios:font-t3-bold ios:tracking-[1px] ios:uppercase"> |
| 101 | New branch |
| 102 | </Text> |
| 103 | <TextInput |
| 104 | value={newBranchName} |
| 105 | onChangeText={setNewBranchName} |
| 106 | placeholder="feature/mobile-polish" |
| 107 | accessibilityLabel="New branch name" |
| 108 | className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" |
| 109 | /> |
| 110 | <SheetActionButton |
| 111 | icon="plus" |
| 112 | label="Create & checkout" |
| 113 | tone="primary" |
| 114 | disabled={!canChangeThreadBranch || busy || newBranchName.trim().length === 0} |
| 115 | onPress={() => { |
| 116 | const branch = sanitizeFeatureBranchName(newBranchName.trim()); |
| 117 | if (branch.length === 0) return; |
| 118 | void gitActions.onCreateSelectedThreadBranch(branch).then((result) => { |
| 119 | if (result === null) return; |
| 120 | setNewBranchName(""); |
| 121 | navigation.goBack(); |
| 122 | }); |
| 123 | }} |
| 124 | /> |
| 125 | </View> |
| 126 | |
| 127 | <View className="bg-card android:gap-3 android:rounded-[20px] android:p-4 ios:gap-2 ios:rounded-[18px] ios:border ios:border-border ios:px-4 ios:py-4"> |
| 128 | <Text className="android:text-foreground android:text-base android:font-t3-medium ios:text-foreground-secondary ios:text-2xs ios:font-t3-bold ios:tracking-[1px] ios:uppercase"> |
| 129 | New worktree |
| 130 | </Text> |
| 131 | {Platform.OS === "android" ? ( |
| 132 | <Text className="text-foreground-secondary text-sm">Base branch</Text> |
| 133 | ) : null} |
| 134 | <TextInput |
| 135 | value={worktreeBaseBranch} |
| 136 | onChangeText={setWorktreeBaseBranch} |
| 137 | placeholder="main" |
| 138 | accessibilityLabel="Worktree base branch" |
| 139 | className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" |
| 140 | /> |
| 141 | {Platform.OS === "android" ? ( |
| 142 | <Text className="text-foreground-secondary text-sm">New branch</Text> |
| 143 | ) : null} |
| 144 | <TextInput |
| 145 | value={worktreeBranchName} |
| 146 | onChangeText={setWorktreeBranchName} |
| 147 | placeholder="feature/mobile-thread" |
| 148 | accessibilityLabel="Worktree branch name" |
| 149 | className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" |
| 150 | /> |
| 151 | <SheetActionButton |
| 152 | icon="square.split.2x1" |
| 153 | label="Create worktree" |
| 154 | tone="primary" |
| 155 | disabled={ |
| 156 | !canChangeThreadBranch || |
| 157 | busy || |
| 158 | worktreeBaseBranch.trim().length === 0 || |
| 159 | worktreeBranchName.trim().length === 0 |
| 160 | } |
| 161 | onPress={() => { |
| 162 | const baseBranch = worktreeBaseBranch.trim(); |
| 163 | const newBranch = worktreeBranchName.trim(); |
| 164 | if (baseBranch.length === 0 || newBranch.length === 0) return; |
| 165 | void gitActions |
| 166 | .onCreateSelectedThreadWorktree({ baseBranch, newBranch }) |
| 167 | .then((result) => { |
| 168 | if (result === null) return; |
| 169 | setWorktreeBranchName(""); |
| 170 | navigation.goBack(); |
| 171 | }); |
| 172 | }} |
| 173 | /> |
| 174 | </View> |
| 175 | |
| 176 | <View className="gap-2"> |
| 177 | <Text className="text-foreground-secondary android:px-4 android:pb-1 android:pt-3 android:text-sm android:font-t3-medium ios:text-2xs ios:font-t3-bold ios:tracking-[1px] ios:uppercase"> |
| 178 | Existing branches |
| 179 | </Text> |
| 180 | {branchesLoading ? ( |
| 181 | <Text className="text-foreground-secondary text-sm font-medium android:px-4"> |
| 182 | Loading branches... |
| 183 | </Text> |
| 184 | ) : null} |
| 185 | {!branchesLoading && availableBranches.length === 0 ? ( |
| 186 | <Text className="text-foreground-secondary text-sm font-medium android:px-4"> |
| 187 | No local branches found. |
| 188 | </Text> |
| 189 | ) : null} |
| 190 | {availableBranches.map((branch) => { |
| 191 | const disabled = disabledExistingBranches.has(branch.name); |
| 192 | const subtitle = branch.worktreePath |
| 193 | ? branch.worktreePath === currentWorktreePath |
| 194 | ? "Checked out in this thread" |
| 195 | : "Checked out in another worktree" |
| 196 | : branch.isDefault |
| 197 | ? "Default branch" |
| 198 | : "Local branch"; |
| 199 | |
| 200 | return ( |
| 201 | <Pressable |
| 202 | key={branch.name} |
| 203 | className={cn( |
| 204 | "gap-1 px-4 py-3 disabled:opacity-[0.45] android:rounded-[20px] android:active:bg-subtle ios:rounded-[18px] ios:border", |
| 205 | branch.current |
| 206 | ? "android:bg-secondary ios:border-subtle-strong" |
| 207 | : "android:bg-card ios:border-border", |
| 208 | )} |
| 209 | accessibilityRole="button" |
| 210 | accessibilityState={{ selected: branch.current, disabled: busy || disabled }} |
| 211 | disabled={!canChangeThreadBranch || busy || disabled} |
| 212 | onPress={() => { |
| 213 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => { |
| 214 | if (result === null) return; |
| 215 | navigation.goBack(); |
| 216 | }); |
| 217 | }} |
| 218 | > |
| 219 | {Platform.OS !== "android" ? ( |
| 220 | <View className="absolute inset-0 rounded-[18px] bg-card" /> |
| 221 | ) : null} |
| 222 | <Text className="text-foreground text-base android:font-t3-medium ios:font-t3-bold"> |
| 223 | {branch.name} |
| 224 | </Text> |
| 225 | <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text> |
| 226 | </Pressable> |
| 227 | ); |
| 228 | })} |
| 229 | </View> |
| 230 | </ScrollView> |
| 231 | </MaterialScreenContent> |
| 232 | </View> |
| 233 | ); |
| 234 | } |