1import { AppActivityIndicator } from "../../components/AppActivityIndicator";
2import { MaterialListRow } from "../../components/MaterialListRow";
3import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
4import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation";
5import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
6import { AuthSourceControlWriteScope, resolveEnvironmentMachineKind } from "@t3tools/contracts";
7import { LegendList } from "@legendapp/list/react-native";
8import {
9 isAtomCommandInterrupted,
10 squashAtomCommandFailure,
11} from "@t3tools/client-runtime/state/runtime";
12import * as Haptics from "expo-haptics";
13import { useNavigation } from "@react-navigation/native";
14import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
15import { Alert, Platform, Pressable, ScrollView, TextInput, View } from "react-native";
16import { useSafeAreaInsets } from "react-native-safe-area-context";
17
18import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
19import { MaterialScreenContent } from "../../components/MaterialScreenContent";
20import { SymbolView } from "../../components/AppSymbol";
21import { AppText as Text } from "../../components/AppText";
22import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
23import { ThemedSwitch } from "../../components/ThemedSwitch";
24import { cn } from "../../lib/cn";
25import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
26import { useServerConfigs } from "../../state/entities";
27import { useEnvironmentScope } from "../../state/session";
28import { useAtomCommand } from "../../state/use-atom-command";
29import { vcsEnvironment } from "../../state/vcs";
30import {
31 createNativeMailSearchToolbarItem,
32 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
33} from "../layout/native-mail-search-toolbar";
34import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
35import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
36import { checkoutNewTaskBranch } from "./checkout-new-task-branch";
37
38function SelectionRow(props: {
39 readonly icon?: "arrow.triangle.branch" | ReactNode;
40 readonly onPress: () => void;
41 readonly disabled?: boolean;
42 readonly selected: boolean;
43 readonly isLast?: boolean;
44 readonly subtitle?: string;
45 readonly title: string;
46}) {
47 if (Platform.OS === "android") {
48 return (
49 <MaterialListRow
50 className="bg-grouped-card"
51 title={props.title}
52 subtitle={props.subtitle}
53 leading={
54 props.icon === "arrow.triangle.branch" ? (
55 <SymbolView
56 name="arrow.triangle.branch"
57 size={24}
58 tintColorClassName="accent-icon-muted"
59 />
60 ) : (
61 props.icon
62 )
63 }
64 trailing={
65 props.selected ? (
66 <SymbolView name="checkmark" size={20} tintColorClassName="accent-focus" />
67 ) : null
68 }
69 accessibilityRole="radio"
70 accessibilityState={{ checked: props.selected }}
71 disabled={props.disabled}
72 onPress={props.onPress}
73 />
74 );
75 }
76 return (
77 <Pressable
78 accessibilityLabel={[props.title, props.subtitle].filter(Boolean).join(", ")}
79 accessibilityRole="radio"
80 accessibilityState={{ checked: props.selected }}
81 className={cn(
82 "min-h-14 flex-row items-center gap-3 bg-grouped-card px-4 py-3 active:bg-subtle",
83 !props.isLast && "border-b border-border-subtle",
84 )}
85 disabled={props.disabled}
86 onPress={props.onPress}
87 style={{ opacity: props.disabled ? 0.45 : 1 }}
88 >
89 {props.icon === "arrow.triangle.branch" ? (
90 <SymbolView
91 name="arrow.triangle.branch"
92 size={17}
93 tintColorClassName="accent-icon-muted"
94 type="monochrome"
95 />
96 ) : (
97 (props.icon ?? null)
98 )}
99 <View className="min-w-0 flex-1 gap-0.5">
100 <Text className="text-base font-t3-medium text-foreground" numberOfLines={1}>
101 {props.title}
102 </Text>
103 {props.subtitle ? (
104 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
105 {props.subtitle}
106 </Text>
107 ) : null}
108 </View>
109 {props.selected ? (
110 <SymbolView
111 name="checkmark"
112 size={16}
113 tintColorClassName="accent-icon"
114 type="monochrome"
115 weight="semibold"
116 />
117 ) : null}
118 </Pressable>
119 );
120}
121
122function ToggleRow(props: {
123 readonly title: string;
124 readonly value: boolean;
125 readonly onValueChange: (value: boolean) => void;
126}) {
127 return (
128 <View className="min-h-14 flex-row items-center gap-3 bg-grouped-card px-4 py-3">
129 <Text
130 className={cn(
131 "min-w-0 flex-1 text-base text-foreground",
132 Platform.OS !== "android" && "font-t3-medium",
133 )}
134 numberOfLines={1}
135 >
136 {props.title}
137 </Text>
138 <ThemedSwitch
139 accessibilityLabel={props.title}
140 onValueChange={props.onValueChange}
141 value={props.value}
142 />
143 </View>
144 );
145}
146
147function BranchSelectionRow(props: {
148 readonly badge: string | null;
149 readonly branch: VcsRef;
150 readonly disabled: boolean;
151 readonly isFirst: boolean;
152 readonly isLast: boolean;
153 readonly onSelect: (branch: VcsRef) => void;
154 readonly selected: boolean;
155}) {
156 const onPress = useCallback(() => props.onSelect(props.branch), [props.branch, props.onSelect]);
157
158 return (
159 <View
160 className={cn(
161 props.isFirst &&
162 (Platform.OS === "android"
163 ? "overflow-hidden rounded-t-[28px]"
164 : "overflow-hidden rounded-t-2xl"),
165 props.isLast &&
166 (Platform.OS === "android"
167 ? "overflow-hidden rounded-b-[28px]"
168 : "overflow-hidden rounded-b-2xl"),
169 )}
170 >
171 <SelectionRow
172 icon="arrow.triangle.branch"
173 disabled={props.disabled}
174 isLast={props.isLast}
175 onPress={onPress}
176 selected={props.selected}
177 subtitle={props.badge ? props.badge.toUpperCase() : undefined}
178 title={props.branch.name}
179 />
180 </View>
181 );
182}
183
184function PickerSurface(props: { readonly children: ReactNode }) {
185 return (
186 <View
187 className={
188 Platform.OS === "android"
189 ? "overflow-hidden rounded-[28px] bg-grouped-card"
190 : "overflow-hidden rounded-2xl bg-grouped-card"
191 }
192 >
193 {props.children}
194 </View>
195 );
196}
197
198export function NewTaskEnvironmentPickerRouteScreen() {
199 const flow = useNewTaskFlow();
200 const navigation = useNavigation();
201 const insets = useSafeAreaInsets();
202 const serverConfigs = useServerConfigs();
203 return (
204 <View className="flex-1 bg-sheet" collapsable={false}>
205 <NativeStackScreenOptions
206 options={{
207 headerShown: Platform.OS !== "android",
208 title: "Environment",
209 }}
210 />
211 {Platform.OS === "android" ? (
212 <AndroidScreenHeader
213 title="Environment"
214 hideBottomBorder
215 onBack={() => navigation.goBack()}
216 />
217 ) : null}
218 <MaterialScreenContent>
219 <ScrollView
220 contentInsetAdjustmentBehavior="automatic"
221 contentContainerStyle={{
222 paddingBottom: Math.max(insets.bottom, 16) + 16,
223 paddingHorizontal: 16,
224 paddingTop: 16,
225 }}
226 showsVerticalScrollIndicator={false}
227 >
228 <PickerSurface>
229 {flow.environments.map((environment, index) => (
230 <SelectionRow
231 key={String(environment.environmentId)}
232 icon={
233 <EnvironmentMachineSymbol
234 kind={resolveEnvironmentMachineKind(
235 serverConfigs.get(environment.environmentId) ?? null,
236 )}
237 size={Platform.OS === "android" ? 24 : 17}
238 tintColorClassName="accent-icon-muted"
239 />
240 }
241 isLast={index === flow.environments.length - 1}
242 disabled={flow.switchingToEnvironmentId !== null}
243 onPress={() => {
244 void Haptics.selectionAsync();
245 void flow.switchEnvironment(environment.environmentId).then((switched) => {
246 if (switched) navigation.goBack();
247 });
248 }}
249 selected={flow.selectedEnvironmentId === environment.environmentId}
250 title={environment.environmentLabel}
251 />
252 ))}
253 </PickerSurface>
254 </ScrollView>
255 </MaterialScreenContent>
256 </View>
257 );
258}
259
260export function NewTaskBranchPickerRouteScreen() {
261 const flow = useNewTaskFlow();
262 const canWriteSourceControl = useEnvironmentScope(
263 flow.selectedProject?.environmentId ?? null,
264 AuthSourceControlWriteScope,
265 );
266 const navigation = useNavigation();
267 const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
268 const [switchingBranchName, setSwitchingBranchName] = useState<string | null>(null);
269 const selectingBranchNameRef = useRef<string | null>(null);
270 const allowSelectionNavigationRef = useRef(false);
271 const mountedRef = useRef(true);
272
273 useEffect(() => {
274 mountedRef.current = true;
275 return () => {
276 mountedRef.current = false;
277 flow.setBranchQuery("");
278 };
279 }, [flow.setBranchQuery]);
280
281 useEffect(
282 () =>
283 navigation.addListener("beforeRemove", (event) => {
284 if (selectingBranchNameRef.current !== null && !allowSelectionNavigationRef.current) {
285 event.preventDefault();
286 }
287 }),
288 [navigation],
289 );
290
291 const selectBranch = useCallback(
292 async (branch: VcsRef) => {
293 const needsCheckout = shouldCheckoutNewTaskBranch({
294 branchIsCurrent: branch.current,
295 branchWorktreePath: branch.worktreePath,
296 workspaceMode: flow.workspaceMode,
297 });
298 if (selectingBranchNameRef.current !== null || (needsCheckout && !canWriteSourceControl)) {
299 return;
300 }
301 selectingBranchNameRef.current = branch.name;
302 void Haptics.selectionAsync();
303
304 try {
305 if (!flow.selectedProject) return;
306 setSwitchingBranchName(branch.name);
307 const result = await checkoutNewTaskBranch({
308 branch,
309 project: flow.selectedProject,
310 workspaceMode: flow.workspaceMode,
311 switchRef,
312 });
313 if (result._tag === "Failure") {
314 if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) {
315 const error = squashAtomCommandFailure(result);
316 Alert.alert(
317 "Could not switch branch",
318 error instanceof Error ? error.message : "The branch could not be checked out.",
319 );
320 }
321 return;
322 }
323
324 // The checkout has already changed the repository. Persist the matching
325 // draft selection even if the native sheet was dismissed while the
326 // command was in flight; only visible-screen work is focus-gated below.
327 flow.selectBranch(result.value);
328 if (!mountedRef.current || !navigation.isFocused()) {
329 return;
330 }
331 flow.setBranchQuery("");
332 allowSelectionNavigationRef.current = true;
333 navigation.goBack();
334 } finally {
335 selectingBranchNameRef.current = null;
336 allowSelectionNavigationRef.current = false;
337 if (mountedRef.current) {
338 setSwitchingBranchName(null);
339 }
340 }
341 },
342 [
343 canWriteSourceControl,
344 flow.selectBranch,
345 flow.selectedProject,
346 flow.setBranchQuery,
347 flow.workspaceMode,
348 navigation,
349 switchRef,
350 ],
351 );
352
353 return (
354 <BranchPickerScreen
355 title={flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}
356 project={flow.selectedProject}
357 branches={flow.filteredBranches}
358 selectedBranchName={
359 flow.selectedBranchName ??
360 flow.availableBranches.find((branch) => branch.current)?.name ??
361 flow.availableBranches.find((branch) => branch.isDefault)?.name ??
362 null
363 }
364 query={flow.branchQuery}
365 onQueryChange={flow.setBranchQuery}
366 loading={flow.branchesLoading}
367 error={flow.branchesError}
368 refreshing={flow.branchesFetchingNextPage}
369 hasMore={flow.hasMoreBranches}
370 onRefresh={flow.loadBranches}
371 onLoadMore={flow.loadMoreBranches}
372 selectionDisabled={switchingBranchName !== null}
373 onSelect={selectBranch}
374 worktree={
375 flow.workspaceMode === "worktree"
376 ? {
377 startFromOrigin: flow.startFromOrigin,
378 onChangeStartFromOrigin: flow.setStartFromOrigin,
379 }
380 : undefined
381 }
382 />
383 );
384}
385
386/** The searchable branch screen shared by new threads and scheduled tasks. */
387export function BranchPickerScreen(props: {
388 readonly title: string;
389 readonly project: EnvironmentProject | null;
390 readonly branches: readonly VcsRef[];
391 readonly selectedBranchName: string | null;
392 readonly query: string;
393 readonly onQueryChange: (query: string) => void;
394 readonly loading: boolean;
395 readonly error: string | null;
396 readonly refreshing: boolean;
397 readonly hasMore: boolean;
398 readonly onRefresh: () => void;
399 readonly onLoadMore: () => void;
400 readonly selectionDisabled?: boolean;
401 readonly onSelect: (branch: VcsRef) => void;
402 readonly worktree?: {
403 readonly startFromOrigin: boolean;
404 readonly onChangeStartFromOrigin: (value: boolean) => void;
405 };
406}) {
407 const navigation = useNavigation();
408 const insets = useSafeAreaInsets();
409 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
410 const selectedBranchName =
411 props.selectedBranchName ??
412 props.branches.find((branch) => branch.current)?.name ??
413 props.branches.find((branch) => branch.isDefault)?.name ??
414 null;
415 const branchListContentStyle = useMemo(
416 () => ({
417 paddingBottom: usesNativeMailSearchToolbar
418 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16
419 : Platform.OS === "ios"
420 ? 16
421 : Math.max(insets.bottom, 16) + 16,
422 paddingHorizontal: 16,
423 paddingTop: 16,
424 }),
425 [insets.bottom, usesNativeMailSearchToolbar],
426 );
427
428 const renderBranch = useCallback(
429 ({ item, index }: { readonly item: VcsRef; readonly index: number }) => (
430 <BranchSelectionRow
431 badge={branchBadgeLabel({ branch: item, project: props.project })}
432 branch={item}
433 disabled={props.selectionDisabled ?? false}
434 isFirst={index === 0}
435 isLast={index === props.branches.length - 1}
436 onSelect={props.onSelect}
437 selected={selectedBranchName === item.name}
438 />
439 ),
440 [
441 props.branches.length,
442 props.project,
443 props.onSelect,
444 selectedBranchName,
445 props.selectionDisabled,
446 ],
447 );
448
449 const branchListHeader = props.worktree ? (
450 <View
451 className={cn(
452 "mb-3 overflow-hidden",
453 Platform.OS === "android" ? "rounded-[28px]" : "rounded-2xl",
454 )}
455 >
456 <ToggleRow
457 onValueChange={props.worktree.onChangeStartFromOrigin}
458 title="Start from origin"
459 value={props.worktree.startFromOrigin}
460 />
461 </View>
462 ) : null;
463
464 const branchContent =
465 props.branches.length === 0 ? (
466 <ScrollView
467 className="flex-1 bg-sheet android:bg-sheet-solid"
468 contentInsetAdjustmentBehavior="automatic"
469 contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 16 }}
470 scrollEnabled={false}
471 showsVerticalScrollIndicator={false}
472 >
473 {branchListHeader}
474 <View
475 className="flex-1 items-center justify-center gap-3 px-4"
476 style={{
477 marginBottom: usesNativeMailSearchToolbar
478 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET
479 : 0,
480 }}
481 >
482 {props.loading ? <AppActivityIndicator /> : null}
483 <Text className="text-center text-sm text-foreground-muted">
484 {props.loading
485 ? "Loading branches…"
486 : props.error
487 ? props.error
488 : props.query
489 ? "No matching branches"
490 : "No branches available"}
491 </Text>
492 {!props.loading && props.error ? (
493 <Pressable
494 accessibilityRole="button"
495 className="rounded-full bg-card px-4 py-2 active:opacity-70"
496 onPress={props.onRefresh}
497 >
498 <Text className="text-sm font-t3-medium text-foreground">Try again</Text>
499 </Pressable>
500 ) : null}
501 </View>
502 </ScrollView>
503 ) : (
504 <LegendList
505 alwaysBounceVertical={false}
506 automaticallyAdjustsScrollIndicatorInsets
507 automaticallyAdjustKeyboardInsets={Platform.OS === "ios"}
508 className="flex-1 bg-sheet android:bg-sheet-solid"
509 contentInsetAdjustmentBehavior="automatic"
510 contentContainerStyle={branchListContentStyle}
511 data={props.branches}
512 keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
513 keyboardShouldPersistTaps="handled"
514 keyExtractor={(branch) =>
515 `${branch.remoteName ?? "local"}:${branch.name}:${branch.worktreePath ?? ""}`
516 }
517 ListHeaderComponent={branchListHeader}
518 ListFooterComponent={
519 props.refreshing ? (
520 <View className="items-center py-4">
521 <AppActivityIndicator />
522 </View>
523 ) : null
524 }
525 onEndReached={props.hasMore ? props.onLoadMore : undefined}
526 onEndReachedThreshold={0.35}
527 renderItem={renderBranch}
528 showsVerticalScrollIndicator={false}
529 />
530 );
531
532 if (Platform.OS === "android") {
533 return (
534 <View className="flex-1 bg-sheet" collapsable={false}>
535 <NativeStackScreenOptions options={{ headerShown: false }} />
536 <AndroidScreenHeader
537 title={props.title}
538 hideBottomBorder
539 onBack={() => navigation.goBack()}
540 />
541 <View className="bg-header px-4 pb-3 pt-1">
542 <TextInput
543 autoCapitalize="none"
544 autoCorrect={false}
545 accessibilityLabel="Find a branch"
546 className="h-12 rounded-full border border-input-border bg-input px-4 font-sans text-base text-foreground"
547 selectionColorClassName="accent-focus/32"
548 cursorColorClassName="accent-focus"
549 selectionHandleColorClassName="accent-focus"
550 onChangeText={props.onQueryChange}
551 placeholder="Find a branch"
552 placeholderTextColorClassName="accent-placeholder"
553 value={props.query}
554 />
555 </View>
556 <MaterialScreenContent>{branchContent}</MaterialScreenContent>
557 </View>
558 );
559 }
560
561 return (
562 <>
563 <NativeStackScreenOptions
564 options={{
565 headerShown: true,
566 title: props.title,
567 unstable_headerToolbarItems: usesNativeMailSearchToolbar
568 ? () => [
569 createNativeMailSearchToolbarItem({
570 onSearchTextChange: props.onQueryChange,
571 placeholder: "Find a branch",
572 searchTextChangeId: "new-task-branch-search-text",
573 showsSearchDismissButton: true,
574 }),
575 ]
576 : undefined,
577 headerSearchBarOptions: usesNativeMailSearchToolbar
578 ? undefined
579 : {
580 allowToolbarIntegration: true,
581 autoCapitalize: "none",
582 hideNavigationBar: false,
583 obscureBackground: false,
584 placeholder: "Find a branch",
585 onChangeText: (event) => {
586 props.onQueryChange(event.nativeEvent.text);
587 },
588 onCancelButtonPress: () => {
589 props.onQueryChange("");
590 },
591 },
592 }}
593 />
594 {usesNativeMailSearchToolbar ? null : (
595 <NativeHeaderToolbar placement="bottom">
596 <NativeHeaderToolbar.SearchBarSlot />
597 </NativeHeaderToolbar>
598 )}
599 {branchContent}
600 </>
601 );
602}