Open History to see the last commit
1import { FitSheet } from "../../../components/ui/FitSheet";
2import { SheetHeader } from "../../../components/ui/SheetHeader";
3import { Button } from "../../../components/ui/Button";
4import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
5import { useState } from "react";
6import { Pressable, View } from "react-native";
7import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
8
9import { SymbolView } from "../../../components/ui/AppSymbol";
10import { AppText as Text, AppTextInput as TextInput } from "../../../components/ui/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";
18
19type GitCommitSheetProps = StaticScreenProps<{
20 readonly environmentId: string;
21 readonly threadId: string;
22}>;
23
24export function GitCommitSheet(_props: GitCommitSheetProps) {
25 const navigation = useNavigation();
26 const { selectedThread } = useThreadSelection();
27 const { selectedThreadCwd } = useSelectedThreadWorktree();
28 const gitState = useSelectedThreadGitState();
29 const gitActions = useSelectedThreadGitActions();
30 const { canWriteSourceControl, canChangeThreadBranch } = gitActions;
31
32 const gitStatus = useEnvironmentQuery(
33 selectedThread !== null && selectedThreadCwd !== null
34 ? vcsEnvironment.status({
35 environmentId: selectedThread.environmentId,
36 input: { cwd: selectedThreadCwd },
37 })
38 : null,
39 );
40
41 const busy = gitState.gitOperationLabel !== null;
42 const isDefaultRef = gitStatus.data?.isDefaultRef ?? false;
43 const allFiles = gitStatus.data?.workingTree?.files ?? [];
44
45 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
46 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
47
48 const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path));
49 const allSelected = excludedFiles.size === 0;
50 const noneSelected = selectedFiles.length === 0;
51 const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0);
52 const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0);
53
54 const runCommitAction = async (featureBranch: boolean) => {
55 if (!canWriteSourceControl || (featureBranch && !canChangeThreadBranch)) return;
56 const commitMessage = dialogCommitMessage.trim();
57 navigation.goBack();
58 await gitActions.onRunSelectedThreadGitAction({
59 action: "commit",
60 featureBranch,
61 ...(commitMessage ? { commitMessage } : {}),
62 ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}),
63 });
64 };
65
66 return (
67 <FitSheet
68 header={
69 <SheetHeader
70 title="Commit changes"
71 subtitle={gitStatus.data?.refName ?? "(detached HEAD)"}
72 onCancel={() => navigation.goBack()}
73 accessibilityLabel="Cancel commit"
74 />
75 }
76 keyboardShouldPersistTaps="handled"
77 keyboardDismissMode="on-drag"
78 automaticallyAdjustKeyboardInsets
79 contentContainerClassName="px-5 pt-4 gap-5"
80 >
81 {isDefaultRef ? (
82 <Text className="text-sm text-warning-foreground">
83 You’re on the default branch. You can create a new branch when committing.
84 </Text>
85 ) : null}
86 <View className="gap-2">
87 <Text className="text-sm font-t3-medium text-foreground">Commit message</Text>
88 <TextInput
89 multiline
90 accessibilityLabel="Commit message"
91 value={dialogCommitMessage}
92 onChangeText={setDialogCommitMessage}
93 placeholder="Describe your changes…"
94 textAlignVertical="top"
95 className="min-h-24 rounded-xl bg-subtle px-4 py-3 text-base"
96 />
97 <Text className="text-xs text-foreground-muted">
98 Leave empty to generate a message automatically.
99 </Text>
100 </View>
101 <View>
102 <View className="flex-row items-center justify-between gap-2 pb-2">
103 <View className="flex-1 gap-1">
104 <Text className="text-base font-t3-bold text-foreground">Changed files</Text>
105 <Text className="text-xs text-foreground-muted">
106 {selectedFiles.length} of {allFiles.length} selected · +
107 {formatDiffCount(selectedInsertions)} −{formatDiffCount(selectedDeletions)}
108 </Text>
109 </View>
110 {allFiles.length > 0 ? (
111 <Button
112 label={allSelected ? "Deselect all" : "Select all"}
113 tone="text"
114 onPress={() =>
115 setExcludedFiles(
116 allSelected ? new Set(allFiles.map((file) => file.path)) : new Set(),
117 )
118 }
119 />
120 ) : null}
121 </View>
122 {allFiles.length === 0 ? (
123 <Text className="py-4 text-sm text-foreground-muted">No changed files to commit.</Text>
124 ) : (
125 allFiles.map((file) => {
126 const included = !excludedFiles.has(file.path);
127 const slash = file.path.lastIndexOf("/");
128 const name = file.path.slice(slash + 1);
129 const directory = slash >= 0 ? file.path.slice(0, slash) : null;
130 return (
131 <Pressable
132 key={file.path}
133 className="min-h-16 flex-row items-center gap-3 border-t border-border-subtle py-3"
134 accessibilityRole="checkbox"
135 accessibilityLabel={file.path}
136 accessibilityState={{ checked: included }}
137 onPress={() =>
138 setExcludedFiles((current) => {
139 const next = new Set(current);
140 if (next.has(file.path)) next.delete(file.path);
141 else next.add(file.path);
142 return next;
143 })
144 }
145 >
146 <View
147 className={cn(
148 "size-5 items-center justify-center rounded-full",
149 included ? "bg-primary" : "border border-input-border",
150 )}
151 >
152 {included ? (
153 <SymbolView
154 name="checkmark"
155 size={13}
156 tintColorClassName="accent-primary-foreground"
157 />
158 ) : null}
159 </View>
160 <View className="flex-1 gap-1">
161 <Text
162 numberOfLines={1}
163 ellipsizeMode="middle"
164 className="text-sm font-t3-medium text-foreground"
165 >
166 {name}
167 </Text>
168 {directory ? (
169 <Text
170 numberOfLines={1}
171 ellipsizeMode="middle"
172 className="text-xs text-foreground-muted"
173 >
174 {directory}
175 </Text>
176 ) : null}
177 </View>
178 <View className="flex-row gap-2">
179 <Text className="text-xs text-adaptive-emerald-700-300">
180 +{formatDiffCount(file.insertions)}
181 </Text>
182 <Text className="text-xs text-adaptive-rose-700-300">
183 −{formatDiffCount(file.deletions)}
184 </Text>
185 </View>
186 </Pressable>
187 );
188 })
189 )}
190 </View>
191 <View className="gap-2">
192 <Button
193 label="Commit changes"
194 icon="checkmark"
195 tone="primary"
196 fullWidth
197 disabled={!canWriteSourceControl || noneSelected || busy}
198 onPress={() => void runCommitAction(false)}
199 />
200 <Button
201 label="Commit on a new branch"
202 icon="arrow.branch"
203 tone="text"
204 fullWidth
205 disabled={!canWriteSourceControl || !canChangeThreadBranch || noneSelected || busy}
206 onPress={() => void runCommitAction(true)}
207 />
208 </View>
209 </FitSheet>
210 );
211}