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