Open History to see the last commit
1import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
2import type { VcsStatusResult } from "@t3tools/contracts";
3import { FileDiffIcon, GitBranchIcon, GitBranchPlusIcon } from "lucide-react";
4import { useId, useState } from "react";
5
6import { cn } from "~/lib/utils";
7import { DialogSubmitKbd, isDialogSubmitShortcut } from "./DialogSubmitShortcut";
8import { StartTruncatedPath } from "./StartTruncatedPath";
9import { Badge } from "./ui/badge";
10import { Button, InlineButton } from "./ui/button";
11import { Checkbox } from "./ui/checkbox";
12import {
13 Dialog,
14 DialogFooter,
15 DialogHeader,
16 DialogPanel,
17 DialogPopup,
18 DialogTitle,
19} from "./ui/dialog";
20import { Label } from "./ui/label";
21import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
22import { Textarea } from "./ui/textarea";
23
24type CommitBranchTarget = "current" | "new";
25
26export interface GitCommitDialogSubmission {
27 readonly commitMessage: string | null;
28 /** Absent when every changed file is included. */
29 readonly filePaths: ReadonlyArray<string> | null;
30 /** Commit on a new, automatically named branch instead of the current one. */
31 readonly featureBranch: boolean;
32}
33
34function splitFilePath(path: string): { readonly name: string; readonly directory: string } {
35 const separator = path.lastIndexOf("/");
36 return separator === -1
37 ? { name: path, directory: "" }
38 : { name: path.slice(separator + 1), directory: path.slice(0, separator) };
39}
40
41/**
42 * Review and commit the working tree: pick the branch to commit on, include or exclude files,
43 * and write an optional message. The caller runs the commit.
44 */
45export function GitCommitDialog({
46 open,
47 onOpenChange,
48 gitStatus,
49 isDefaultRef,
50 canWriteSourceControl,
51 canChangeThreadBranch,
52 canOpenFiles,
53 onSubmit,
54 onOpenFile,
55}: {
56 open: boolean;
57 onOpenChange: (open: boolean) => void;
58 gitStatus: VcsStatusResult | null;
59 isDefaultRef: boolean;
60 canWriteSourceControl: boolean;
61 /** A new branch also moves the thread onto it. */
62 canChangeThreadBranch: boolean;
63 canOpenFiles: boolean;
64 onSubmit: (submission: GitCommitDialogSubmission) => void;
65 onOpenFile: (path: string) => void;
66}) {
67 const branchFieldId = useId();
68 const messageFieldId = useId();
69 const [commitMessage, setCommitMessage] = useState("");
70 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(() => new Set());
71 const [branchTarget, setBranchTarget] = useState<CommitBranchTarget>("current");
72
73 // Every opening starts from the whole working tree on the current branch.
74 const [openSeen, setOpenSeen] = useState(open);
75 if (openSeen !== open) {
76 setOpenSeen(open);
77 if (open) {
78 setCommitMessage("");
79 setExcludedFiles(new Set());
80 setBranchTarget("current");
81 }
82 }
83
84 const allFiles = gitStatus?.workingTree.files ?? [];
85 const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path));
86 const allSelected = selectedFiles.length === allFiles.length;
87 const noneSelected = selectedFiles.length === 0;
88 const excludedCount = allFiles.length - selectedFiles.length;
89 const refName = gitStatus?.refName ?? null;
90 const branchItems = [
91 { value: "current", label: refName ?? "(detached HEAD)" },
92 { value: "new", label: "New branch" },
93 ];
94
95 const toggleFile = (path: string) => {
96 setExcludedFiles((previous) => {
97 const next = new Set(previous);
98 if (next.has(path)) next.delete(path);
99 else next.add(path);
100 return next;
101 });
102 };
103
104 const submitDisabled =
105 noneSelected || !canWriteSourceControl || (branchTarget === "new" && !canChangeThreadBranch);
106 const submit = () => {
107 if (submitDisabled) return;
108 const trimmedMessage = commitMessage.trim();
109 onSubmit({
110 commitMessage: trimmedMessage.length > 0 ? trimmedMessage : null,
111 filePaths: allSelected ? null : selectedFiles.map((file) => file.path),
112 featureBranch: branchTarget === "new",
113 });
114 };
115
116 return (
117 <Dialog open={open} onOpenChange={onOpenChange}>
118 <DialogPopup
119 className="sm:max-w-115"
120 onKeyDown={(event) => {
121 if (!isDialogSubmitShortcut(event)) return;
122 event.preventDefault();
123 submit();
124 }}
125 >
126 <DialogHeader>
127 <DialogTitle>Commit changes</DialogTitle>
128 </DialogHeader>
129 <DialogPanel>
130 <div className="flex flex-col gap-1.5">
131 <Label htmlFor={branchFieldId}>Branch</Label>
132 <Select
133 value={branchTarget}
134 onValueChange={(value) => setBranchTarget(value === "new" ? "new" : "current")}
135 items={branchItems}
136 >
137 <SelectTrigger id={branchFieldId} size="sm">
138 {branchTarget === "new" ? <GitBranchPlusIcon /> : <GitBranchIcon />}
139 <SelectValue />
140 {branchTarget === "current" && isDefaultRef ? (
141 <Badge variant="warning">default</Badge>
142 ) : null}
143 </SelectTrigger>
144 <SelectPopup>
145 <SelectItem value="current">
146 <span className="flex min-w-0 items-center gap-2">
147 <GitBranchIcon />
148 <span className="min-w-0 truncate">{refName ?? "(detached HEAD)"}</span>
149 {isDefaultRef ? <Badge variant="warning">default</Badge> : null}
150 </span>
151 </SelectItem>
152 <SelectItem value="new">
153 <span className="flex min-w-0 items-center gap-2">
154 <GitBranchPlusIcon />
155 <span className="shrink-0">New branch</span>
156 {refName ? (
157 <span className="min-w-0 truncate text-dim-foreground">from {refName}</span>
158 ) : null}
159 </span>
160 </SelectItem>
161 </SelectPopup>
162 </Select>
163 </div>
164
165 <div className="flex flex-col gap-1.5">
166 <div className="flex items-center justify-between gap-2">
167 <Label render={<span />}>Files</Label>
168 {allFiles.length > 1 ? (
169 <span className="text-meta">
170 <InlineButton
171 tone="muted"
172 onClick={() =>
173 setExcludedFiles(
174 allSelected ? new Set(allFiles.map((file) => file.path)) : new Set(),
175 )
176 }
177 >
178 {allSelected ? "Exclude all" : "Include all"}
179 </InlineButton>
180 </span>
181 ) : null}
182 </div>
183 {allFiles.length === 0 ? (
184 <p className="rounded-card surface-card-settings px-3 py-2 text-body text-muted-foreground">
185 No changed files.
186 </p>
187 ) : (
188 <div className="max-h-52 overflow-y-auto">
189 {allFiles.map((file) => {
190 const isExcluded = excludedFiles.has(file.path);
191 const { name, directory } = splitFilePath(file.path);
192 return (
193 <div
194 key={file.path}
195 className={cn(
196 "flex h-8 min-w-0 items-center gap-2.5 px-2",
197 isExcluded && "opacity-55",
198 )}
199 >
200 <Checkbox
201 checked={!isExcluded}
202 aria-label={`Include ${file.path}`}
203 onCheckedChange={() => toggleFile(file.path)}
204 />
205 <button
206 type="button"
207 className="flex min-w-0 flex-1 text-left enabled:cursor-pointer"
208 aria-label={`Open ${file.path}`}
209 disabled={!canOpenFiles}
210 onClick={() => onOpenFile(file.path)}
211 >
212 <span className="flex min-w-0 flex-1 items-center gap-2.5">
213 <FileDiffIcon
214 aria-hidden
215 className="size-3.5 shrink-0 text-diff-modified"
216 />
217 <span className="min-w-0 shrink truncate text-body text-foreground">
218 {name}
219 </span>
220 {directory ? (
221 <StartTruncatedPath
222 path={directory}
223 className="min-w-0 flex-1 text-meta text-dim-foreground"
224 />
225 ) : (
226 <span className="flex-1" />
227 )}
228 </span>
229 </button>
230 <span
231 className={cn(
232 "shrink-0 font-mono text-meta tabular-nums",
233 isExcluded ? "text-dim-foreground" : "text-muted-foreground",
234 )}
235 >
236 {file.insertions > 0 ? `+${formatDiffCount(file.insertions)}` : ""}
237 {file.insertions > 0 && file.deletions > 0 ? " " : ""}
238 {file.deletions > 0 ? `−${formatDiffCount(file.deletions)}` : ""}
239 </span>
240 </div>
241 );
242 })}
243 </div>
244 )}
245 </div>
246
247 <div className="flex flex-col gap-1.5">
248 <Label htmlFor={messageFieldId}>Commit message (optional)</Label>
249 <Textarea
250 id={messageFieldId}
251 value={commitMessage}
252 onChange={(event) => setCommitMessage(event.target.value)}
253 placeholder="Leave empty to auto-generate"
254 size="sm"
255 />
256 </div>
257 </DialogPanel>
258 <DialogFooter variant="bare" className="sm:items-center">
259 <p className="flex flex-col gap-0.5 text-meta text-dim-foreground sm:mr-auto">
260 <span>
261 {selectedFiles.length} of {allFiles.length} {allFiles.length === 1 ? "file" : "files"}
262 </span>
263 {excludedCount > 0 ? <span>{excludedCount} excluded</span> : null}
264 </p>
265 <Button variant="secondary" size="standard" onClick={() => onOpenChange(false)}>
266 Cancel
267 </Button>
268 <Button size="standard" disabled={submitDisabled} onClick={submit}>
269 {branchTarget === "new" ? "Commit on new branch" : "Commit"}
270 <DialogSubmitKbd />
271 </Button>
272 </DialogFooter>
273 </DialogPopup>
274 </Dialog>
275 );
276}