469 lines · 15.4 KB · TypeScript
RawDownload
import type { VcsRef } from "@t3tools/client-runtime/state/vcs";import { resolveEnvironmentMachineKind } from "@t3tools/contracts";import { LegendList } from "@legendapp/list/react-native";import {  isAtomCommandInterrupted,  squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import * as Haptics from "expo-haptics";import { useNavigation } from "@react-navigation/native";import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";import {  ActivityIndicator,  Alert,  Platform,  Pressable,  ScrollView,  TextInput,  View,} from "react-native";import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";import { SymbolView } from "../../components/AppSymbol";import { AppText as Text } from "../../components/AppText";import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";import { ThemedSwitch } from "../../components/ThemedSwitch";import { cn } from "../../lib/cn";import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";import { useServerConfigs } from "../../state/entities";import { useAtomCommand } from "../../state/use-atom-command";import { vcsEnvironment } from "../../state/vcs";import {  createNativeMailSearchToolbarItem,  NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,  NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,} from "../layout/native-mail-search-toolbar";import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; function SelectionRow(props: {  readonly icon?: "arrow.triangle.branch" | ReactNode;  readonly onPress: () => void;  readonly disabled?: boolean;  readonly selected: boolean;  readonly isLast?: boolean;  readonly subtitle?: string;  readonly title: string;}) {  return (    <Pressable      accessibilityLabel={[props.title, props.subtitle].filter(Boolean).join(", ")}      accessibilityRole="radio"      accessibilityState={{ checked: props.selected }}      className={cn(        "min-h-14 flex-row items-center gap-3 bg-card px-4 py-3 active:bg-subtle",        !props.isLast && "border-b border-border-subtle",      )}      disabled={props.disabled}      onPress={props.onPress}      style={{ opacity: props.disabled ? 0.45 : 1 }}    >      {props.icon === "arrow.triangle.branch" ? (        <SymbolView          name="arrow.triangle.branch"          size={17}          tintColorClassName={"accent-icon-muted"}          type="monochrome"        />      ) : (        (props.icon ?? null)      )}      <View className="min-w-0 flex-1 gap-0.5">        <Text className="text-base font-t3-medium text-foreground" numberOfLines={1}>          {props.title}        </Text>        {props.subtitle ? (          <Text className="text-xs text-foreground-muted" numberOfLines={1}>            {props.subtitle}          </Text>        ) : null}      </View>      {props.selected ? (        <SymbolView          name="checkmark"          size={16}          tintColorClassName={"accent-icon"}          type="monochrome"          weight="semibold"        />      ) : null}    </Pressable>  );} function ToggleRow(props: {  readonly title: string;  readonly value: boolean;  readonly onValueChange: (value: boolean) => void;}) {  return (    <View className="min-h-14 flex-row items-center gap-3 bg-card px-4 py-3">      <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}>        {props.title}      </Text>      <ThemedSwitch        accessibilityLabel={props.title}        onValueChange={props.onValueChange}        value={props.value}      />    </View>  );} function BranchSelectionRow(props: {  readonly badge: string | null;  readonly branch: VcsRef;  readonly disabled: boolean;  readonly isFirst: boolean;  readonly isLast: boolean;  readonly onSelect: (branch: VcsRef) => void;  readonly selected: boolean;}) {  const onPress = useCallback(() => props.onSelect(props.branch), [props.branch, props.onSelect]);   return (    <View      className={cn(        props.isFirst && "overflow-hidden rounded-t-2xl",        props.isLast && "overflow-hidden rounded-b-2xl",      )}    >      <SelectionRow        icon="arrow.triangle.branch"        disabled={props.disabled}        isLast={props.isLast}        onPress={onPress}        selected={props.selected}        subtitle={props.badge ? props.badge.toUpperCase() : undefined}        title={props.branch.name}      />    </View>  );} function PickerSurface(props: { readonly children: ReactNode }) {  return <View className="overflow-hidden rounded-2xl bg-card">{props.children}</View>;} export function NewTaskEnvironmentPickerRouteScreen() {  const flow = useNewTaskFlow();  const navigation = useNavigation();  const insets = useSafeAreaInsets();  const serverConfigs = useServerConfigs();   return (    <View className="flex-1 bg-sheet" collapsable={false}>      <NativeStackScreenOptions        options={{          headerShown: Platform.OS !== "android",          title: "Environment",        }}      />      {Platform.OS === "android" ? (        <AndroidScreenHeader title="Environment" onBack={() => navigation.goBack()} />      ) : null}      <ScrollView        contentInsetAdjustmentBehavior="automatic"        contentContainerStyle={{          paddingBottom: Math.max(insets.bottom, 16) + 16,          paddingHorizontal: 16,          paddingTop: 16,        }}        showsVerticalScrollIndicator={false}      >        <PickerSurface>          {flow.environments.map((environment, index) => (            <SelectionRow              key={String(environment.environmentId)}              icon={                <EnvironmentMachineSymbol                  kind={resolveEnvironmentMachineKind(                    serverConfigs.get(environment.environmentId) ?? null,                  )}                  size={17}                  tintColorClassName="accent-icon-muted"                />              }              isLast={index === flow.environments.length - 1}              onPress={() => {                void Haptics.selectionAsync();                flow.selectEnvironment(environment.environmentId);                navigation.goBack();              }}              selected={flow.selectedEnvironmentId === environment.environmentId}              title={environment.environmentLabel}            />          ))}        </PickerSurface>      </ScrollView>    </View>  );} export function NewTaskBranchPickerRouteScreen() {  const flow = useNewTaskFlow();  const navigation = useNavigation();  const insets = useSafeAreaInsets();  const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });  const [switchingBranchName, setSwitchingBranchName] = useState<string | null>(null);  const selectingBranchNameRef = useRef<string | null>(null);  const allowSelectionNavigationRef = useRef(false);  const mountedRef = useRef(true);  const screenTitle = flow.workspaceMode === "worktree" ? "Base branch" : "Branch";  const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;  const selectedBranchName =    flow.selectedBranchName ??    flow.availableBranches.find((branch) => branch.current)?.name ??    flow.availableBranches.find((branch) => branch.isDefault)?.name ??    null;  const branchListContentStyle = useMemo(    () => ({      paddingBottom: usesNativeMailSearchToolbar        ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16        : Platform.OS === "ios"          ? 16          : Math.max(insets.bottom, 16) + 16,      paddingHorizontal: 16,      paddingTop: 12,    }),    [insets.bottom, usesNativeMailSearchToolbar],  );   useEffect(() => {    mountedRef.current = true;    return () => {      mountedRef.current = false;      flow.setBranchQuery("");    };  }, [flow.setBranchQuery]);   useEffect(    () =>      navigation.addListener("beforeRemove", (event) => {        if (selectingBranchNameRef.current !== null && !allowSelectionNavigationRef.current) {          event.preventDefault();        }      }),    [navigation],  );   const selectBranch = useCallback(    async (branch: VcsRef) => {      if (selectingBranchNameRef.current !== null) {        return;      }      selectingBranchNameRef.current = branch.name;      void Haptics.selectionAsync();       try {        if (!flow.selectedProject) return;        setSwitchingBranchName(branch.name);        const result = await checkoutNewTaskBranch({          branch,          project: flow.selectedProject,          workspaceMode: flow.workspaceMode,          switchRef,        });        if (result._tag === "Failure") {          if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) {            const error = squashAtomCommandFailure(result);            Alert.alert(              "Could not switch branch",              error instanceof Error ? error.message : "The branch could not be checked out.",            );          }          return;        }         // The checkout has already changed the repository. Persist the matching        // draft selection even if the native sheet was dismissed while the        // command was in flight; only visible-screen work is focus-gated below.        flow.selectBranch(result.value);        if (!mountedRef.current || !navigation.isFocused()) {          return;        }        flow.setBranchQuery("");        allowSelectionNavigationRef.current = true;        navigation.goBack();      } finally {        selectingBranchNameRef.current = null;        allowSelectionNavigationRef.current = false;        if (mountedRef.current) {          setSwitchingBranchName(null);        }      }    },    [      flow.selectBranch,      flow.selectedProject,      flow.setBranchQuery,      flow.workspaceMode,      navigation,      switchRef,    ],  );   const renderBranch = useCallback(    ({ item, index }: { readonly item: VcsRef; readonly index: number }) => (      <BranchSelectionRow        badge={branchBadgeLabel({ branch: item, project: flow.selectedProject })}        branch={item}        disabled={switchingBranchName !== null}        isFirst={index === 0}        isLast={index === flow.filteredBranches.length - 1}        onSelect={selectBranch}        selected={selectedBranchName === item.name}      />    ),    [      flow.filteredBranches.length,      flow.selectedProject,      selectBranch,      selectedBranchName,      switchingBranchName,    ],  );   const branchListHeader =    flow.workspaceMode === "worktree" ? (      <View className="mb-3 overflow-hidden rounded-2xl">        <ToggleRow          onValueChange={flow.setStartFromOrigin}          title="Start from origin"          value={flow.startFromOrigin}        />      </View>    ) : null;   const branchContent =    flow.filteredBranches.length === 0 ? (      <ScrollView        className="flex-1 bg-sheet"        contentInsetAdjustmentBehavior="automatic"        contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 16, paddingTop: 12 }}        scrollEnabled={false}        showsVerticalScrollIndicator={false}      >        {branchListHeader}        <View          className="flex-1 items-center justify-center gap-3 px-4"          style={{            marginBottom: usesNativeMailSearchToolbar              ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET              : 0,          }}        >          {flow.branchesLoading ? <ActivityIndicator /> : null}          <Text className="text-center text-sm text-foreground-muted">            {flow.branchesLoading              ? "Loading branches…"              : flow.branchesError                ? flow.branchesError                : flow.branchQuery                  ? "No matching branches"                  : "No branches available"}          </Text>          {!flow.branchesLoading && flow.branchesError ? (            <Pressable              accessibilityRole="button"              className="rounded-full bg-card px-4 py-2 active:opacity-70"              onPress={flow.loadBranches}            >              <Text className="text-sm font-t3-medium text-foreground">Try again</Text>            </Pressable>          ) : null}        </View>      </ScrollView>    ) : (      <LegendList        alwaysBounceVertical={false}        automaticallyAdjustsScrollIndicatorInsets        automaticallyAdjustKeyboardInsets={Platform.OS === "ios"}        className="flex-1 bg-sheet"        contentInsetAdjustmentBehavior="automatic"        contentContainerStyle={branchListContentStyle}        data={flow.filteredBranches}        keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}        keyboardShouldPersistTaps="handled"        keyExtractor={(branch) =>          `${branch.remoteName ?? "local"}:${branch.name}:${branch.worktreePath ?? ""}`        }        ListHeaderComponent={branchListHeader}        ListFooterComponent={          flow.branchesFetchingNextPage ? (            <View className="items-center py-4">              <ActivityIndicator />            </View>          ) : null        }        onEndReached={flow.hasMoreBranches ? flow.loadMoreBranches : undefined}        onEndReachedThreshold={0.35}        renderItem={renderBranch}        showsVerticalScrollIndicator={false}      />    );   if (Platform.OS === "android") {    return (      <View className="flex-1 bg-sheet" collapsable={false}>        <NativeStackScreenOptions options={{ headerShown: false }} />        <AndroidScreenHeader title={screenTitle} onBack={() => navigation.goBack()} />        <View className="px-4 pb-2 pt-3">          <TextInput            autoCapitalize="none"            autoCorrect={false}            className="h-11 rounded-xl bg-card px-4 font-sans text-base text-foreground"            onChangeText={flow.setBranchQuery}            placeholder="Find a branch"            placeholderTextColorClassName={"accent-placeholder"}            value={flow.branchQuery}          />        </View>        {branchContent}      </View>    );  }   return (    <>      <NativeStackScreenOptions        options={{          headerShown: true,          title: screenTitle,          unstable_headerToolbarItems: usesNativeMailSearchToolbar            ? () => [                createNativeMailSearchToolbarItem({                  onSearchTextChange: flow.setBranchQuery,                  placeholder: "Find a branch",                  searchTextChangeId: "new-task-branch-search-text",                  showsSearchDismissButton: true,                }),              ]            : undefined,          headerSearchBarOptions: usesNativeMailSearchToolbar            ? undefined            : {                allowToolbarIntegration: true,                autoCapitalize: "none",                hideNavigationBar: false,                obscureBackground: false,                placeholder: "Find a branch",                onChangeText: (event) => {                  flow.setBranchQuery(event.nativeEvent.text);                },                onCancelButtonPress: () => {                  flow.setBranchQuery("");                },              },        }}      />      {usesNativeMailSearchToolbar ? null : (        <NativeHeaderToolbar placement="bottom">          <NativeHeaderToolbar.SearchBarSlot />        </NativeHeaderToolbar>      )}      {branchContent}    </>  );}