233 lines · 9.8 KB · TypeScript
RawDownload
import { useNavigation, type StaticScreenProps } from "@react-navigation/native";import { useCallback, useState } from "react";import { Platform, Pressable, ScrollView, View } from "react-native";import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";import { cn } from "../../../lib/cn";import { useEnvironmentQuery } from "../../../state/query";import { useThreadSelection } from "../../../state/use-thread-selection";import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";import { vcsEnvironment } from "../../../state/vcs";import { SheetActionButton } from "./gitSheetComponents"; type GitCommitSheetProps = StaticScreenProps<{  readonly environmentId: string;  readonly threadId: string;}>; export function GitCommitSheet(_props: GitCommitSheetProps) {  const navigation = useNavigation();  const insets = useSafeAreaInsets();  const { selectedThread } = useThreadSelection();  const { selectedThreadCwd } = useSelectedThreadWorktree();  const gitState = useSelectedThreadGitState();  const gitActions = useSelectedThreadGitActions();   const gitStatus = useEnvironmentQuery(    selectedThread !== null && selectedThreadCwd !== null      ? vcsEnvironment.status({          environmentId: selectedThread.environmentId,          input: { cwd: selectedThreadCwd },        })      : null,  );   const busy = gitState.gitOperationLabel !== null;  const isDefaultRef = gitStatus.data?.isDefaultRef ?? false;  const allFiles = gitStatus.data?.workingTree?.files ?? [];   const [dialogCommitMessage, setDialogCommitMessage] = useState("");  const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());  const [isEditingFiles, setIsEditingFiles] = useState(false);   const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path));  const allSelected = excludedFiles.size === 0;  const noneSelected = selectedFiles.length === 0;  const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0);  const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0);  const selectedFilePreview = selectedFiles.slice(0, 3);   const runCommitAction = useCallback(    async (featureBranch: boolean) => {      const commitMessage = dialogCommitMessage.trim();      navigation.goBack();      await gitActions.onRunSelectedThreadGitAction({        action: "commit",        featureBranch,        ...(commitMessage ? { commitMessage } : {}),        ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),      });    },    [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles],  );   return (    <View collapsable={false} className="flex-1 bg-sheet">      {Platform.OS === "android" ? (        <AndroidSheetHeader title="Commit changes" onBack={() => navigation.goBack()} />      ) : null}      <ScrollView        className="flex-1"        keyboardShouldPersistTaps="handled"        showsVerticalScrollIndicator={false}        contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}        contentContainerClassName="gap-4 px-5 pt-2"      >        <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">          <View className="flex-row items-center justify-between gap-3">            <Text className="text-foreground-muted text-sm font-medium">Branch</Text>            <Text className="text-foreground text-base font-t3-bold">              {gitStatus.data?.refName ?? "(detached HEAD)"}            </Text>          </View>          {isDefaultRef ? (            <Text className="text-xs leading-normal text-warning-foreground">              Warning: this is the default branch.            </Text>          ) : null}        </View>         <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">          <View className="flex-row items-center justify-between gap-3">            <View className="gap-1">              <Text className="text-foreground text-base font-t3-bold">Files</Text>              <Text className="text-foreground-muted text-xs leading-normal">                {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}              </Text>            </View>            <View className="flex-row items-center gap-2">              {!allSelected && isEditingFiles ? (                <Pressable                  className="bg-subtle rounded-full px-3 py-2"                  onPress={() => setExcludedFiles(new Set())}                >                  <Text className="text-foreground text-2xs font-t3-bold uppercase">Reset</Text>                </Pressable>              ) : null}              <Pressable                className="bg-subtle rounded-full px-3 py-2"                onPress={() => setIsEditingFiles((current) => !current)}              >                <Text className="text-foreground text-2xs font-t3-bold uppercase">                  {isEditingFiles ? "Done" : "Edit"}                </Text>              </Pressable>            </View>          </View>           {allFiles.length === 0 ? (            <Text className="text-foreground-secondary text-sm leading-normal">              No changed files are available to commit.            </Text>          ) : !isEditingFiles ? (            <View className="gap-2">              {selectedFilePreview.map((file) => (                <View key={file.path} className="flex-row items-center justify-between gap-3">                  <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}>                    {file.path}                  </Text>                  <Text className="text-xs font-t3-bold text-emerald-500">+{file.insertions}</Text>                  <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text>                </View>              ))}              {selectedFiles.length > selectedFilePreview.length ? (                <Text className="text-foreground-muted text-xs leading-snug">                  +{selectedFiles.length - selectedFilePreview.length} more files                </Text>              ) : null}            </View>          ) : (            <View className="gap-2">              {allFiles.map((file) => {                const included = !excludedFiles.has(file.path);                return (                  <Pressable                    key={file.path}                    className={cn(                      "rounded-[18px] border px-4 py-3",                      included ? "border-border" : "border-border-subtle",                    )}                    onPress={() => {                      setExcludedFiles((current) => {                        const next = new Set(current);                        if (next.has(file.path)) {                          next.delete(file.path);                        } else {                          next.add(file.path);                        }                        return next;                      });                    }}                  >                    <View                      className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`}                    />                    <View className="flex-row items-start justify-between gap-3">                      <View className="flex-1 gap-1">                        <Text                          selectable                          className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`}                        >                          {file.path}                        </Text>                        {!included ? (                          <Text className="text-foreground-muted text-2xs leading-normal">                            Excluded from this commit                          </Text>                        ) : null}                      </View>                      <View className="items-end gap-1">                        <Text className="text-xs font-t3-bold text-emerald-500">                          +{file.insertions}                        </Text>                        <Text className="text-xs font-t3-bold text-rose-500">                          -{file.deletions}                        </Text>                      </View>                    </View>                  </Pressable>                );              })}            </View>          )}        </View>         <View className="gap-2">          <Text className="text-foreground text-sm font-t3-bold">Commit message</Text>          <TextInput            multiline            value={dialogCommitMessage}            onChangeText={setDialogCommitMessage}            placeholder="Leave empty to auto-generate"            textAlignVertical="top"            className="min-h-[128px] rounded-[20px] px-4 py-3.5"          />        </View>         <View className="flex-row gap-3">          <View className="flex-1">            <SheetActionButton              icon="arrow.branch"              label="Commit on new branch"              disabled={noneSelected || busy}              onPress={() => void runCommitAction(true)}            />          </View>          <View className="flex-1">            <SheetActionButton              icon="checkmark.circle"              label="Commit"              tone="primary"              disabled={noneSelected || busy}              onPress={() => void runCommitAction(false)}            />          </View>        </View>      </ScrollView>    </View>  );}