authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-14 22:36:16-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-14 23:35:48-08:00
log85eb379dd769db2f949a3352acca6537b02e89cf
treeceb261480dcdfbca53b79335c54106dba7184b89
parent5a30594009cc1170cd181649899c86b5452eff15

Improve git status details and streamline quick action flows

- add working-tree file/line stats to git status contracts, server responses, and tests - refactor web git queries/mutations into shared React Query helpers - update git quick-action logic/UI to prefer commit/push/PR flows and clearer disabled states - hide branch toolbar for non-repos and tighten related chat/toolbar behavior

12 files changed, 871 insertions(+), 353 deletions(-)

apps/server/src/git.ts+85-2
......@@ -94,6 +94,51 @@ function parseBranchAb(value: string): { ahead: number; behind: number } {
9494 };
9595}
9696
97function parseNumstatEntries(
98 stdout: string,
99): Array<{ path: string; insertions: number; deletions: number }> {
100 const entries: Array<{ path: string; insertions: number; deletions: number }> = [];
101 for (const line of stdout.split(/\r?\n/g)) {
102 if (line.trim().length === 0) continue;
103 const [addedRaw, deletedRaw, ...pathParts] = line.split("\t");
104 const rawPath = pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim();
105 if (rawPath.length === 0) continue;
106 const added = Number.parseInt(addedRaw ?? "0", 10);
107 const deleted = Number.parseInt(deletedRaw ?? "0", 10);
108 const renameArrowIndex = rawPath.indexOf(" => ");
109 const normalizedPath =
110 renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath;
111 entries.push({
112 path: normalizedPath.length > 0 ? normalizedPath : rawPath,
113 insertions: Number.isFinite(added) ? added : 0,
114 deletions: Number.isFinite(deleted) ? deleted : 0,
115 });
116 }
117 return entries;
118}
119
120function parsePorcelainPath(line: string): string | null {
121 if (line.startsWith("? ") || line.startsWith("! ")) {
122 const simple = line.slice(2).trim();
123 return simple.length > 0 ? simple : null;
124 }
125
126 if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) {
127 return null;
128 }
129
130 const tabIndex = line.indexOf("\t");
131 if (tabIndex >= 0) {
132 const fromTab = line.slice(tabIndex + 1);
133 const [path] = fromTab.split("\t");
134 return path?.trim().length ? path.trim() : null;
135 }
136
137 const parts = line.trim().split(/\s+/g);
138 const path = parts.at(-1) ?? "";
139 return path.length > 0 ? path : null;
140}
141
97142function commandLabel(args: readonly string[]): string {
98143 return `git ${args.join(" ")}`;
99144}
......@@ -176,6 +221,7 @@ export class GitCoreService {
176221 return gitStatusResultSchema.parse({
177222 branch: details.branch,
178223 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
224 workingTree: details.workingTree,
179225 hasUpstream: details.hasUpstream,
180226 aheadCount: details.aheadCount,
181227 behindCount: details.behindCount,
......@@ -184,15 +230,20 @@ export class GitCoreService {
184230 }
185231
186232 async statusDetails(cwd: string): Promise<GitStatusDetails> {
187 const stdout = await this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]);
233 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([
234 this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]),
235 this.gitStdout(cwd, ["diff", "--numstat"]),
236 this.gitStdout(cwd, ["diff", "--cached", "--numstat"]),
237 ]);
188238
189239 let branch: string | null = null;
190240 let upstreamRef: string | null = null;
191241 let aheadCount = 0;
192242 let behindCount = 0;
193243 let hasWorkingTreeChanges = false;
244 const changedFilesWithoutNumstat = new Set<string>();
194245
195 for (const line of stdout.split(/\r?\n/g)) {
246 for (const line of statusStdout.split(/\r?\n/g)) {
196247 if (line.startsWith("# branch.head ")) {
197248 const value = line.slice("# branch.head ".length).trim();
198249 branch = value.startsWith("(") ? null : value;
......@@ -212,13 +263,45 @@ export class GitCoreService {
212263 }
213264 if (line.trim().length > 0 && !line.startsWith("#")) {
214265 hasWorkingTreeChanges = true;
266 const pathValue = parsePorcelainPath(line);
267 if (pathValue) changedFilesWithoutNumstat.add(pathValue);
215268 }
216269 }
270 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
271 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
272 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
273 for (const entry of [...stagedEntries, ...unstagedEntries]) {
274 const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };
275 existing.insertions += entry.insertions;
276 existing.deletions += entry.deletions;
277 fileStatMap.set(entry.path, existing);
278 }
279
280 let insertions = 0;
281 let deletions = 0;
282 const files = Array.from(fileStatMap.entries())
283 .map(([path, stat]) => {
284 insertions += stat.insertions;
285 deletions += stat.deletions;
286 return { path, insertions: stat.insertions, deletions: stat.deletions };
287 })
288 .toSorted((a, b) => a.path.localeCompare(b.path));
289
290 for (const filePath of changedFilesWithoutNumstat) {
291 if (fileStatMap.has(filePath)) continue;
292 files.push({ path: filePath, insertions: 0, deletions: 0 });
293 }
294 files.sort((a, b) => a.path.localeCompare(b.path));
217295
218296 return {
219297 branch,
220298 upstreamRef,
221299 hasWorkingTreeChanges,
300 workingTree: {
301 files,
302 insertions,
303 deletions,
304 },
222305 hasUpstream: upstreamRef !== null,
223306 aheadCount,
224307 behindCount,
apps/server/src/gitManager.ts+1
......@@ -218,6 +218,7 @@ export class GitManager {
218218 return {
219219 branch: details.branch,
220220 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
221 workingTree: details.workingTree,
221222 hasUpstream: details.hasUpstream,
222223 aheadCount: details.aheadCount,
223224 behindCount: details.behindCount,
apps/server/src/wsServer.test.ts+10
......@@ -557,6 +557,11 @@ describe("WebSocket Server", () => {
557557 status: vi.fn().mockResolvedValue({
558558 branch: "feature/test",
559559 hasWorkingTreeChanges: true,
560 workingTree: {
561 files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
562 insertions: 7,
563 deletions: 2,
564 },
560565 hasUpstream: false,
561566 aheadCount: 0,
562567 behindCount: 0,
......@@ -581,6 +586,11 @@ describe("WebSocket Server", () => {
581586 expect(response.result).toEqual({
582587 branch: "feature/test",
583588 hasWorkingTreeChanges: true,
589 workingTree: {
590 files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
591 insertions: 7,
592 deletions: 2,
593 },
584594 hasUpstream: false,
585595 aheadCount: 0,
586596 behindCount: 0,
apps/web/src/components/BranchToolbar.tsx+118-152
......@@ -3,6 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
33import { useEffect, useState } from "react";
44
55import { useNativeApi } from "../hooks/useNativeApi";
6import {
7 gitBranchesQueryOptions,
8 gitCheckoutMutationOptions,
9 gitCreateBranchAndCheckoutMutationOptions,
10} from "../lib/gitReactQuery";
611import { useStore } from "../store";
712import { Button } from "./ui/button";
813import {
......@@ -37,28 +42,19 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
3742 const activeThreadId = activeThread?.id;
3843 const activeThreadBranch = activeThread?.branch ?? null;
3944 const activeWorktreePath = activeThread?.worktreePath ?? null;
40 const branchCwd = activeWorktreePath ?? activeProject?.cwd;
45 const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null;
4146
4247 // ── Queries ───────────────────────────────────────────────────────────
4348
44 const branchesQuery = useQuery({
45 queryKey: ["git", "branches", branchCwd],
46 queryFn: () => api!.git.listBranches({ cwd: branchCwd! }),
47 enabled: !!api && !!branchCwd,
48 });
49 const branchesQuery = useQuery(gitBranchesQueryOptions(api, branchCwd));
4950
5051 const branches = branchesQuery.data?.branches ?? [];
5152 const branchNames = branches.map((branch) => branch.name);
5253 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));
53 // Default to true while loading — showing "Initialize git" during a fetch is wrong,
54 // and worktrees are inherently git repos.
55 const isRepo = branchesQuery.data?.isRepo ?? !branchesQuery.isLoading;
56
5754 // ── Mutations ─────────────────────────────────────────────────────────
5855
5956 const checkoutMutation = useMutation({
60 mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }),
61 onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
57 ...gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
6258 onError: (error) => {
6359 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
6460 setIsBranchMenuOpen(true);
......@@ -66,25 +62,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
6662 });
6763
6864 const createBranchMutation = useMutation({
69 mutationFn: (branch: string) =>
70 api!.git
71 .createBranch({ cwd: branchCwd!, branch })
72 .then(() => api!.git.checkout({ cwd: branchCwd!, branch })),
73 onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
65 ...gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
7466 onError: (error) =>
7567 setThreadError(error instanceof Error ? error.message : "Failed to create branch."),
7668 });
7769
78 const initMutation = useMutation({
79 mutationFn: () => api!.git.init({ cwd: branchCwd! }),
80 onSuccess: () => {
81 setThreadError(null);
82 queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] });
83 },
84 onError: (error) =>
85 setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo."),
86 });
87
8870 // ── Effects ───────────────────────────────────────────────────────────
8971
9072 // Keep thread branch synced to git current branch for local threads.
......@@ -194,134 +176,118 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
194176 )}
195177 </div>
196178
197 {!isRepo ? (
198 <button
199 type="button"
200 className="inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[12px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-50"
201 disabled={!branchCwd || initMutation.isPending}
202 onClick={() => initMutation.mutate()}
203 >
204 {initMutation.isPending ? "Initializing\u2026" : "Initialize git"}
205 </button>
206 ) : (
207 <Combobox
208 items={branchNames}
209 autoHighlight
210 onOpenChange={(open) => setIsBranchMenuOpen(open)}
211 open={isBranchMenuOpen}
212 value={activeThread.branch}
179 <Combobox
180 items={branchNames}
181 autoHighlight
182 onOpenChange={(open) => setIsBranchMenuOpen(open)}
183 open={isBranchMenuOpen}
184 value={activeThread.branch}
185 >
186 <ComboboxTrigger
187 render={<Button variant="ghost" size="xs" />}
188 className="text-muted-foreground/70 hover:text-foreground/80"
189 disabled={branchesQuery.isLoading}
213190 >
214 <ComboboxTrigger
215 render={<Button variant="ghost" size="xs" />}
216 className="text-muted-foreground/70 hover:text-foreground/80"
217 disabled={branchesQuery.isLoading}
218 >
219 <span className="max-w-[240px] truncate">
220 {activeThread.branch
221 ? envMode === "worktree" && !activeWorktreePath
222 ? `From ${activeThread.branch}`
223 : activeThread.branch
224 : "Select branch"}
225 </span>
226 <ChevronDownIcon />
227 </ComboboxTrigger>
228 <ComboboxPopup align="end" side="top" className="w-64">
229 <div className="border-b">
230 <ComboboxInput
231 className="rounded-b-none before:rounded-b-none [&_input]:font-sans"
232 placeholder="Search branches..."
233 showClear
234 showTrigger={false}
235 size="sm"
236 />
237 </div>
238 <ComboboxEmpty>No branches found.</ComboboxEmpty>
191 <span className="max-w-[240px] truncate">
192 {activeThread.branch
193 ? envMode === "worktree" && !activeWorktreePath
194 ? `From ${activeThread.branch}`
195 : activeThread.branch
196 : "Select branch"}
197 </span>
198 <ChevronDownIcon />
199 </ComboboxTrigger>
200 <ComboboxPopup align="end" side="top" className="w-64">
201 <div className="border-b">
202 <ComboboxInput
203 className="rounded-b-none before:rounded-b-none [&_input]:font-sans"
204 placeholder="Search branches..."
205 showClear
206 showTrigger={false}
207 size="sm"
208 />
209 </div>
210 <ComboboxEmpty>No branches found.</ComboboxEmpty>
239211
240 <ComboboxList className="max-h-56">
241 {(branchName) => {
242 const branch = branchByName.get(branchName);
243 if (!branch) return null;
212 <ComboboxList className="max-h-56">
213 {(branchName) => {
214 const branch = branchByName.get(branchName);
215 if (!branch) return null;
244216
245 const hasSecondaryWorktree =
246 branch.worktreePath && branch.worktreePath !== activeProject.cwd;
247 return (
248 <ComboboxItem
249 hideIndicator
250 key={branchName}
251 value={branchName}
252 className={branchName === activeThread.branch ? "bg-accent text-foreground" : undefined}
253 onClick={() => selectBranch(branch)}
254 >
255 <div className="flex w-full items-center justify-between gap-2">
256 <span className="truncate">{branchName}</span>
257 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
258 <span className="shrink-0 text-[10px] text-muted-foreground/45">
259 {branch.current
260 ? "current"
261 : hasSecondaryWorktree
262 ? "worktree"
263 : "default"}
264 </span>
265 )}
266 </div>
267 </ComboboxItem>
268 );
269 }}
270 </ComboboxList>
271 {envMode === "local" && (
272 <>
273 {isCreatingBranch ? (
274 <form
275 onSubmit={(event) => {
276 event.preventDefault();
277 createBranch();
278 }}
279 >
280 <InputGroup className="rounded-t-none before:rounded-t-none">
281 <InputGroupInput
282 className="[&_input]:font-sans"
283 size="sm"
284 placeholder="branch-name"
285 type="text"
286 value={newBranchName}
287 onChange={(event) => setNewBranchName(event.target.value)}
288 onKeyDown={(event) => {
289 if (event.key === "Escape") {
290 event.stopPropagation();
291 setIsCreatingBranch(false);
292 setNewBranchName("");
293 }
294 }}
295 autoFocus
296 />
297 <InputGroupAddon align="inline-end">
298 <Button
299 size="xs"
300 variant="secondary"
301 type="submit"
302 disabled={!newBranchName.trim() || createBranchMutation.isPending}
303 >
304 Create
305 </Button>
306 </InputGroupAddon>
307 </InputGroup>
308 </form>
309 ) : (
310 <Button
311 size="sm"
312 className="w-full justify-start h-9 rounded-t-none rounded-b-lg before:rounded-t-none"
313 variant="outline"
314 onClick={() => setIsCreatingBranch(true)}
315 >
316 <PlusIcon />
317 New branch
318 </Button>
319 )}
320 </>
321 )}
322 </ComboboxPopup>
323 </Combobox>
324 )}
217 const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProject.cwd;
218 return (
219 <ComboboxItem
220 hideIndicator
221 key={branchName}
222 value={branchName}
223 className={branchName === activeThread.branch ? "bg-accent text-foreground" : undefined}
224 onClick={() => selectBranch(branch)}
225 >
226 <div className="flex w-full items-center justify-between gap-2">
227 <span className="truncate">{branchName}</span>
228 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
229 <span className="shrink-0 text-[10px] text-muted-foreground/45">
230 {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"}
231 </span>
232 )}
233 </div>
234 </ComboboxItem>
235 );
236 }}
237 </ComboboxList>
238 {envMode === "local" && (
239 <>
240 {isCreatingBranch ? (
241 <form
242 onSubmit={(event) => {
243 event.preventDefault();
244 createBranch();
245 }}
246 >
247 <InputGroup className="rounded-t-none before:rounded-t-none">
248 <InputGroupInput
249 className="[&_input]:font-sans"
250 size="sm"
251 placeholder="branch-name"
252 type="text"
253 value={newBranchName}
254 onChange={(event) => setNewBranchName(event.target.value)}
255 onKeyDown={(event) => {
256 if (event.key === "Escape") {
257 event.stopPropagation();
258 setIsCreatingBranch(false);
259 setNewBranchName("");
260 }
261 }}
262 autoFocus
263 />
264 <InputGroupAddon align="inline-end">
265 <Button
266 size="xs"
267 variant="secondary"
268 type="submit"
269 disabled={!newBranchName.trim() || createBranchMutation.isPending}
270 >
271 Create
272 </Button>
273 </InputGroupAddon>
274 </InputGroup>
275 </form>
276 ) : (
277 <Button
278 size="sm"
279 className="w-full justify-start h-9 rounded-t-none rounded-b-lg before:rounded-t-none"
280 variant="outline"
281 onClick={() => setIsCreatingBranch(true)}
282 >
283 <PlusIcon />
284 New branch
285 </Button>
286 )}
287 </>
288 )}
289 </ComboboxPopup>
290 </Combobox>
325291 </div>
326292 );
327293}
apps/web/src/components/ChatView.tsx+8-3
......@@ -19,6 +19,8 @@ import {
1919 useRef,
2020 useState,
2121} from "react";
22import { useQuery } from "@tanstack/react-query";
23import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
2224
2325import { EDITORS, type EditorId } from "@t3tools/contracts";
2426import { isElectron } from "../env";
......@@ -244,6 +246,9 @@ export default function ChatView() {
244246 sandboxMode: "workspace-write",
245247 } as const);
246248 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
249 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
250 // Default true while loading to avoid toolbar flicker.
251 const isGitRepo = branchesQuery.data?.isRepo ?? true;
247252
248253 const envLocked = Boolean(
249254 activeThread &&
......@@ -1123,7 +1128,7 @@ export default function ChatView() {
11231128 </div>
11241129
11251130 {/* Input bar */}
1126 <div className="px-5 pb-1 pt-2">
1131 <div className={cn("px-5 pt-2", isGitRepo ? "pb-1" : "pb-4")}>
11271132 <form onSubmit={onSend} className="mx-auto max-w-3xl">
11281133 <div
11291134 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${
......@@ -1299,7 +1304,7 @@ export default function ChatView() {
12991304 </form>
13001305 </div>
13011306
1302 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
1307 {isGitRepo && <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />}
13031308
13041309 {activeThread.terminalOpen && api && activeProject && (
13051310 <ThreadTerminalDrawer
......@@ -1436,8 +1441,8 @@ function OpenInPicker() {
14361441
14371442 const api = useNativeApi();
14381443 const { state } = useStore();
1439 const activeProject = state.projects.find((p) => p.id === state.activeThreadId);
14401444 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
1445 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
14411446
14421447 const openInEditor = useCallback(
14431448 (editorId: EditorId | null) => {
apps/web/src/components/GitActionsControl.logic.test.ts+73-18
......@@ -10,6 +10,11 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
1010 return {
1111 branch: "feature/test",
1212 hasWorkingTreeChanges: false,
13 workingTree: {
14 files: [],
15 insertions: 0,
16 deletions: 0,
17 },
1318 hasUpstream: true,
1419 aheadCount: 0,
1520 behindCount: 0,
......@@ -81,7 +86,7 @@ describe("when: actions are busy", () => {
8186 const quick = resolveQuickAction(status(), true);
8287 assert.deepInclude(quick, {
8388 kind: "show_hint",
84 label: "Running...",
89 label: "Commit",
8590 disabled: true,
8691 hint: "Git action in progress.",
8792 });
......@@ -123,7 +128,7 @@ describe("when: git status is unavailable", () => {
123128 const quick = resolveQuickAction(null, false);
124129 assert.deepInclude(quick, {
125130 kind: "show_hint",
126 label: "Git actions",
131 label: "Commit",
127132 disabled: true,
128133 hint: "Git status is unavailable.",
129134 });
......@@ -314,9 +319,34 @@ describe("when: branch is behind upstream", () => {
314319});
315320
316321describe("when: working tree has local changes", () => {
317 it("resolveQuickAction returns commit", () => {
322 it("resolveQuickAction returns commit, push, and create PR", () => {
318323 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false);
319 assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" });
324 assert.deepInclude(quick, {
325 kind: "run_action",
326 action: "commit_push_pr",
327 label: "Commit, push & create PR",
328 });
329 });
330
331 it("resolveQuickAction returns commit and push when open PR exists", () => {
332 const quick = resolveQuickAction(
333 status({
334 hasWorkingTreeChanges: true,
335 openPr: {
336 number: 16,
337 title: "Existing PR",
338 url: "https://example.com/pr/16",
339 baseBranch: "main",
340 headBranch: "feature/test",
341 },
342 }),
343 false,
344 );
345 assert.deepInclude(quick, {
346 kind: "run_action",
347 action: "commit_push",
348 label: "Commit & push",
349 });
320350 });
321351
322352 it("buildMenuItems enables commit and disables push and PR", () => {
......@@ -350,10 +380,36 @@ describe("when: working tree has local changes", () => {
350380 });
351381});
352382
383describe("when: on default branch without open PR", () => {
384 it("resolveQuickAction returns commit and push when local changes exist", () => {
385 const quick = resolveQuickAction(status({ branch: "main", hasWorkingTreeChanges: true }), false, true);
386 assert.deepInclude(quick, {
387 kind: "run_action",
388 action: "commit_push",
389 label: "Commit & push",
390 disabled: false,
391 });
392 });
393
394 it("resolveQuickAction returns push when branch is ahead", () => {
395 const quick = resolveQuickAction(status({ branch: "main", aheadCount: 2, openPr: null }), false, true);
396 assert.deepInclude(quick, {
397 kind: "run_action",
398 action: "commit_push",
399 label: "Push",
400 disabled: false,
401 });
402 });
403});
404
353405describe("when: working tree has local changes and branch is behind upstream", () => {
354 it("resolveQuickAction still prefers commit", () => {
406 it("resolveQuickAction still prefers commit, push, and create PR", () => {
355407 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true, behindCount: 1 }), false);
356 assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" });
408 assert.deepInclude(quick, {
409 kind: "run_action",
410 action: "commit_push_pr",
411 label: "Commit, push & create PR",
412 });
357413 });
358414
359415 it("buildMenuItems enables commit and keeps push and PR disabled", () => {
......@@ -393,7 +449,7 @@ describe("when: HEAD is detached and there are no local changes", () => {
393449 status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }),
394450 false,
395451 );
396 assert.deepInclude(quick, { kind: "show_hint", label: "Detached HEAD", disabled: false });
452 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
397453 });
398454
399455 it("buildMenuItems keeps commit, push, and PR disabled", () => {
......@@ -428,17 +484,16 @@ describe("when: HEAD is detached and there are no local changes", () => {
428484});
429485
430486describe("when: branch has no upstream configured", () => {
431 it("resolveQuickAction runs push and create PR when clean and no open PR", () => {
487 it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => {
432488 const quick = resolveQuickAction(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false);
433489 assert.deepInclude(quick, {
434 kind: "run_action",
435 action: "commit_push_pr",
436 label: "Push & create PR",
437 disabled: false,
490 kind: "show_hint",
491 label: "Commit",
492 disabled: true,
438493 });
439494 });
440495
441 it("resolveQuickAction opens PR when clean, no upstream, and no local commits ahead", () => {
496 it("resolveQuickAction remains disabled when clean, no upstream, and no local commits ahead", () => {
442497 const quick = resolveQuickAction(
443498 status({
444499 hasUpstream: false,
......@@ -454,9 +509,9 @@ describe("when: branch has no upstream configured", () => {
454509 false,
455510 );
456511 assert.deepInclude(quick, {
457 kind: "open_pr",
458 label: "Open PR",
459 disabled: false,
512 kind: "show_hint",
513 label: "Commit",
514 disabled: true,
460515 });
461516 });
462517
......@@ -483,7 +538,7 @@ describe("when: branch has no upstream configured", () => {
483538 });
484539 });
485540
486 it("buildMenuItems enables push and disables create PR", () => {
541 it("buildMenuItems disables push and create PR when no commits are ahead", () => {
487542 const items = buildMenuItems(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false);
488543 assert.deepEqual(items, [
489544 {
......@@ -497,7 +552,7 @@ describe("when: branch has no upstream configured", () => {
497552 {
498553 id: "push",
499554 label: "Push",
500 disabled: false,
555 disabled: true,
501556 icon: "push",
502557 kind: "open_dialog",
503558 dialogAction: "push",
apps/web/src/components/GitActionsControl.logic.ts+34-36
......@@ -61,7 +61,7 @@ export function buildMenuItems(
6161 hasBranch &&
6262 !hasChanges &&
6363 !isBehind &&
64 (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream);
64 gitStatus.aheadCount > 0;
6565 const canCreatePr =
6666 !isBusy &&
6767 hasBranch &&
......@@ -111,14 +111,15 @@ export function buildMenuItems(
111111export function resolveQuickAction(
112112 gitStatus: GitStatusResult | null,
113113 isBusy: boolean,
114 isDefaultBranch = false,
114115): GitQuickAction {
115116 if (isBusy) {
116 return { label: "Running...", disabled: true, kind: "show_hint", hint: "Git action in progress." };
117 return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };
117118 }
118119
119120 if (!gitStatus) {
120121 return {
121 label: "Git actions",
122 label: "Commit",
122123 disabled: true,
123124 kind: "show_hint",
124125 hint: "Git status is unavailable.",
......@@ -128,14 +129,31 @@ export function resolveQuickAction(
128129 const hasBranch = gitStatus.branch !== null;
129130 const hasChanges = gitStatus.hasWorkingTreeChanges;
130131 const hasOpenPr = gitStatus.openPr !== null;
132 const isAhead = gitStatus.aheadCount > 0;
131133 const isBehind = gitStatus.behindCount > 0;
132 const canPush = hasBranch && !hasChanges && !isBehind;
133134
134 if (!hasChanges && hasOpenPr && gitStatus.aheadCount === 0 && !isBehind) {
135 return { label: "Open PR", disabled: false, kind: "open_pr" };
135 if (!hasBranch) {
136 return {
137 label: "Commit",
138 disabled: true,
139 kind: "show_hint",
140 hint: "Create and checkout a branch before pushing or opening a PR.",
141 };
136142 }
137143
138 if (!hasChanges && isBehind) {
144 if (hasChanges) {
145 if (hasOpenPr || isDefaultBranch) {
146 return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };
147 }
148 return {
149 label: "Commit, push & create PR",
150 disabled: false,
151 kind: "run_action",
152 action: "commit_push_pr",
153 };
154 }
155
156 if (isBehind) {
139157 return {
140158 label: "Pull",
141159 disabled: false,
......@@ -143,8 +161,8 @@ export function resolveQuickAction(
143161 };
144162 }
145163
146 if (!hasChanges && canPush && (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream)) {
147 if (hasOpenPr) {
164 if (isAhead) {
165 if (hasOpenPr || isDefaultBranch) {
148166 return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" };
149167 }
150168 return {
......@@ -155,36 +173,16 @@ export function resolveQuickAction(
155173 };
156174 }
157175
158 if (hasChanges) {
159 return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };
160 }
161
162 if (hasOpenPr) {
176 if (hasOpenPr && gitStatus.hasUpstream) {
163177 return { label: "Open PR", disabled: false, kind: "open_pr" };
164178 }
165179
166 if (hasBranch && gitStatus.hasUpstream && !isBehind) {
167 if (gitStatus.aheadCount > 0) {
168 return { label: "Create PR", disabled: false, kind: "run_action", action: "commit_push_pr" };
169 }
170 return {
171 label: "Commit",
172 disabled: true,
173 kind: "show_hint",
174 hint: "Branch is up to date. Nothing to commit, push, pull, or open as a PR.",
175 };
176 }
177
178 if (!hasBranch) {
179 return {
180 label: "Detached HEAD",
181 disabled: false,
182 kind: "show_hint",
183 hint: "Create and checkout a branch before pushing or opening a PR.",
184 };
185 }
186
187 return { label: "Commit", disabled: true, kind: "show_hint", hint: "No git action needed." };
180 return {
181 label: "Commit",
182 disabled: true,
183 kind: "show_hint",
184 hint: "Branch is up to date. Nothing to commit, push, pull, or open as a PR.",
185 };
188186}
189187
190188export function requiresDefaultBranchConfirmation(
apps/web/src/components/GitActionsControl.tsx+386-138
......@@ -1,8 +1,9 @@
11import {
2 type GitStatusResult,
23 type GitStackedAction,
34 type NativeApi,
45} from "@t3tools/contracts";
5import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
67import { useCallback, useMemo, useState } from "react";
78import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
89import { GitHubIcon } from "./Icons";
......@@ -16,6 +17,7 @@ import {
1617 requiresDefaultBranchConfirmation,
1718 resolveQuickAction,
1819} from "./GitActionsControl.logic";
20import { Badge } from "~/components/ui/badge";
1921import { Button } from "~/components/ui/button";
2022import {
2123 Dialog,
......@@ -28,14 +30,97 @@ import {
2830} from "~/components/ui/dialog";
2931import { Group, GroupSeparator } from "~/components/ui/group";
3032import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
33import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
3134import { Textarea } from "~/components/ui/textarea";
3235import { toastManager } from "~/components/ui/toast";
36import {
37 gitBranchesQueryOptions,
38 gitInitMutationOptions,
39 gitPullMutationOptions,
40 gitRunStackedActionMutationOptions,
41 gitStatusQueryOptions,
42 invalidateGitQueries,
43} from "~/lib/gitReactQuery";
44import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
3345
3446interface GitActionsControlProps {
3547 api: NativeApi | undefined;
3648 gitCwd: string | null;
3749}
3850
51function getMenuActionDisabledReason(
52 item: GitActionMenuItem,
53 gitStatus: GitStatusResult | null,
54 isBusy: boolean,
55): string | null {
56 if (!item.disabled) return null;
57 if (isBusy) return "Git action in progress.";
58 if (!gitStatus) return "Git status is unavailable.";
59
60 const hasBranch = gitStatus.branch !== null;
61 const hasChanges = gitStatus.hasWorkingTreeChanges;
62 const hasOpenPr = gitStatus.openPr !== null;
63 const isAhead = gitStatus.aheadCount > 0;
64 const isBehind = gitStatus.behindCount > 0;
65
66 if (item.id === "commit") {
67 if (!hasChanges) {
68 return "Worktree is clean. Make changes before committing.";
69 }
70 return "Commit is currently unavailable.";
71 }
72
73 if (item.id === "push") {
74 if (!hasBranch) {
75 return "Detached HEAD: checkout a branch before pushing.";
76 }
77 if (hasChanges) {
78 return "Commit or stash local changes before pushing.";
79 }
80 if (isBehind) {
81 return "Branch is behind upstream. Pull/rebase before pushing.";
82 }
83 if (!isAhead) {
84 return "No local commits to push.";
85 }
86 return "Push is currently unavailable.";
87 }
88
89 if (hasOpenPr) {
90 return "Open PR is currently unavailable.";
91 }
92 if (!hasBranch) {
93 return "Detached HEAD: checkout a branch before creating a PR.";
94 }
95 if (hasChanges) {
96 return "Commit local changes before creating a PR.";
97 }
98 if (!isAhead) {
99 return "No local commits to include in a PR.";
100 }
101 if (!gitStatus.hasUpstream) {
102 return "Set an upstream branch before creating a PR.";
103 }
104 if (isBehind) {
105 return "Branch is behind upstream. Pull/rebase before creating a PR.";
106 }
107 return "Create PR is currently unavailable.";
108}
109
110function formatUpstreamStatus(input: {
111 hasUpstream: boolean;
112 aheadCount: number;
113 behindCount: number;
114}): string {
115 if (!input.hasUpstream) return "No upstream configured";
116 if (input.aheadCount === 0 && input.behindCount === 0) return "Up to date";
117 if (input.aheadCount > 0 && input.behindCount > 0) {
118 return `Diverged (+${input.aheadCount} / -${input.behindCount})`;
119 }
120 if (input.aheadCount > 0) return `Ahead by ${input.aheadCount}`;
121 return `Behind by ${input.behindCount}`;
122}
123
39124function GitActionItemIcon({ icon }: { icon: GitActionIconName }) {
40125 if (icon === "commit") return <GitCommitIcon />;
41126 if (icon === "push") return <CloudUploadIcon />;
......@@ -54,21 +139,20 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
54139 return <InfoIcon className="size-4" />;
55140}
56141
57function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] {
58 return ["git", "status", gitCwd];
59}
60
61function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null) {
62 return queryOptions({
63 queryKey: getGitStatusQueryKey(gitCwd),
64 queryFn: async () => {
65 if (!api || !gitCwd) {
66 throw new Error("Git status is unavailable.");
67 }
68 return api.git.status({ cwd: gitCwd });
69 },
70 enabled: !!api && !!gitCwd,
71 });
142function buildGitActionProgressStages(input: {
143 action: GitStackedAction;
144 hasCustomCommitMessage: boolean;
145}): string[] {
146 const commitStages = input.hasCustomCommitMessage
147 ? ["Committing..."]
148 : ["Generating commit message...", "Committing..."];
149 if (input.action === "commit") {
150 return commitStages;
151 }
152 if (input.action === "commit_push") {
153 return [...commitStages, "Pushing..."];
154 }
155 return [...commitStages, "Pushing...", "Creating PR..."];
72156}
73157
74158export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
......@@ -80,41 +164,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
80164 gitStatusQueryOptions(api, gitCwd),
81165 );
82166
83 const { data: branchList = null } = useQuery({
84 queryKey: ["git", "branches", gitCwd],
85 queryFn: async () => {
86 if (!api || !gitCwd) throw new Error("Git branches are unavailable.");
87 return api.git.listBranches({ cwd: gitCwd });
88 },
89 enabled: !!api && !!gitCwd,
90 });
167 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd));
168 // Default to true while loading so we don't flash init controls.
169 const isRepo = branchList?.isRepo ?? true;
91170
92 const runImmediateGitActionMutation = useMutation({
93 mutationFn: async ({
94 action,
95 commitMessage,
96 }: {
97 action: GitStackedAction;
98 commitMessage?: string;
99 }) => {
100 if (!api || !gitCwd) {
101 throw new Error("Git action is unavailable.");
102 }
103 return api.git.runStackedAction({ cwd: gitCwd, action, ...(commitMessage ? { commitMessage } : {}) });
104 },
105 onSuccess: async () => {
106 await queryClient.invalidateQueries({ queryKey: ["git"] });
107 },
108 });
109 const pullMutation = useMutation({
110 mutationFn: async () => {
111 if (!api || !gitCwd) throw new Error("Git pull is unavailable.");
112 return api.git.pull({ cwd: gitCwd });
113 },
114 onSuccess: async () => {
115 await queryClient.invalidateQueries({ queryKey: ["git"] });
116 },
117 });
171 const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient }));
172
173 const runImmediateGitActionMutation = useMutation(
174 gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }),
175 );
176 const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient }));
118177
119178 const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending;
120179 const isDefaultBranch = useMemo(() => {
......@@ -129,13 +188,20 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
129188 [gitStatus, isGitActionRunning],
130189 );
131190 const quickAction = useMemo(
132 () => resolveQuickAction(gitStatus, isGitActionRunning),
133 [gitStatus, isGitActionRunning],
191 () => resolveQuickAction(gitStatus, isGitActionRunning, isDefaultBranch),
192 [gitStatus, isDefaultBranch, isGitActionRunning],
193 );
194 const quickActionDisabledReason = quickAction.disabled
195 ? (quickAction.hint ?? "This action is currently unavailable.")
196 : null;
197 const [isQuickActionPopoverOpen, setIsQuickActionPopoverOpen] = useState(false);
198 const [openDisabledMenuItemId, setOpenDisabledMenuItemId] = useState<GitActionMenuItem["id"] | null>(
199 null,
134200 );
135201
136202 const refreshGitStatus = useCallback(async () => {
137203 if (!api || !gitCwd) return;
138 await queryClient.invalidateQueries({ queryKey: ["git"] });
204 await invalidateGitQueries(queryClient);
139205 }, [api, gitCwd, queryClient]);
140206
141207 const maybeConfirmPushToDefaultBranch = useCallback(
......@@ -184,31 +250,60 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
184250 const confirmed = await maybeConfirmPushToDefaultBranch(action);
185251 if (!confirmed) return;
186252
253 const progressStages = buildGitActionProgressStages({
254 action,
255 hasCustomCommitMessage: !!commitMessage?.trim(),
256 });
257 const progressToastId = toastManager.add({
258 type: "loading",
259 title: progressStages[0] ?? "Running git action...",
260 timeout: 0,
261 });
262
263 let stageIndex = 0;
264 const stageInterval = setInterval(() => {
265 stageIndex = Math.min(stageIndex + 1, progressStages.length - 1);
266 toastManager.update(progressToastId, {
267 title: progressStages[stageIndex] ?? "Running git action...",
268 type: "loading",
269 timeout: 0,
270 });
271 }, 1100);
272
273 const stopProgressUpdates = () => {
274 clearInterval(stageInterval);
275 };
276
187277 const promise = runImmediateGitActionMutation.mutateAsync({
188278 action,
189279 ...(commitMessage ? { commitMessage } : {}),
190280 });
191 toastManager.promise(promise, {
192 loading: {
193 title:
194 action === "commit"
195 ? "Committing..."
196 : action === "commit_push"
197 ? "Pushing..."
198 : "Creating PR...",
199 },
200 success: (result) => {
201 const prUrl = result.pr.url ?? gitStatus?.openPr?.url;
202 const shouldOfferCreatePrCta =
203 action === "commit_push" &&
204 !prUrl &&
205 result.push.status === "pushed";
206 return {
207 title: "Done",
208 description: describeGitResult(result),
209 ...(prUrl
281
282 try {
283 const result = await promise;
284 stopProgressUpdates();
285
286 const prUrl = result.pr.url ?? gitStatus?.openPr?.url;
287 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
288 const shouldOfferOpenPrCta =
289 (action === "commit_push" || action === "commit_push_pr") && !!prUrl;
290 const shouldOfferCreatePrCta =
291 action === "commit_push" && !prUrl && result.push.status === "pushed";
292
293 toastManager.update(progressToastId, {
294 type: "success",
295 title: "Done",
296 description: describeGitResult(result),
297 timeout: 10_000,
298 ...(shouldOfferPushCta
299 ? {
300 actionProps: {
301 children: "Push",
302 onClick: () => setActiveDialogAction("push"),
303 },
304 }
305 : shouldOfferOpenPrCta
210306 ? {
211 timeout: 10_000,
212307 actionProps: {
213308 children: "Open PR",
214309 onClick: () => void api?.shell.openExternal(prUrl),
......@@ -216,21 +311,21 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
216311 }
217312 : shouldOfferCreatePrCta
218313 ? {
219 timeout: 10_000,
220314 actionProps: {
221315 children: "Create PR",
222316 onClick: () => setActiveDialogAction("create_pr"),
223317 },
224318 }
225319 : {}),
226 };
227 },
228 error: (err) => ({
320 });
321 } catch (err) {
322 stopProgressUpdates();
323 toastManager.update(progressToastId, {
324 type: "error",
229325 title: "Action failed",
230326 description: err instanceof Error ? err.message : "An error occurred.",
231 }),
232 });
233 void promise.catch(() => undefined);
327 });
328 }
234329 },
235330 [
236331 api,
......@@ -309,6 +404,27 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
309404 });
310405 }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]);
311406
407 const openChangedFileInEditor = useCallback(
408 (filePath: string) => {
409 if (!api || !gitCwd) {
410 toastManager.add({
411 type: "error",
412 title: "Editor opening is unavailable.",
413 });
414 return;
415 }
416 const target = resolvePathLinkTarget(filePath, gitCwd);
417 void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => {
418 toastManager.add({
419 type: "error",
420 title: "Unable to open file",
421 description: error instanceof Error ? error.message : "An error occurred.",
422 });
423 });
424 },
425 [api, gitCwd],
426 );
427
312428 const dialogTitle =
313429 activeDialogAction === "commit"
314430 ? "Commit changes"
......@@ -321,65 +437,144 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
321437 : activeDialogAction === "push"
322438 ? "Push this branch now. If this is the default branch, you'll be asked to confirm."
323439 : "Create a pull request using generated title/body content.";
440 const showCompactStatusCard =
441 !!gitStatus && gitStatus.hasWorkingTreeChanges && !gitStatus.hasUpstream;
324442
325443 if (!gitCwd) return null;
326444
327445 return (
328446 <>
329 <Group aria-label="Git actions">
447 {!isRepo ? (
330448 <Button
331449 variant="outline"
332450 size="xs"
333 disabled={isGitActionRunning || quickAction.disabled}
334 onClick={runQuickAction}
451 disabled={initMutation.isPending}
452 onClick={() => initMutation.mutate()}
335453 >
336 <GitQuickActionIcon quickAction={quickAction} />
337 {quickAction.label}
454 {initMutation.isPending ? "Initializing..." : "Initialize Git"}
338455 </Button>
339 <GroupSeparator />
340 <Menu
341 onOpenChange={(open) => {
342 if (open) void refreshGitStatus();
343 }}
344 >
345 <MenuTrigger
346 render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />}
347 disabled={isGitActionRunning}
348 >
349 <ChevronDownIcon aria-hidden="true" className="size-4" />
350 </MenuTrigger>
351 <MenuPopup align="end" sideOffset={4}>
352 {gitActionMenuItems.map((item) => (
353 <MenuItem
354 key={`${item.id}-${item.label}`}
355 disabled={item.disabled}
356 onClick={() => {
357 openDialogForMenuItem(item);
358 }}
456 ) : (
457 <Group aria-label="Git actions">
458 {quickActionDisabledReason ? (
459 <Popover open={isQuickActionPopoverOpen} onOpenChange={setIsQuickActionPopoverOpen}>
460 <PopoverTrigger
461 render={
462 <span
463 className="inline-flex cursor-not-allowed"
464 onBlur={() => setIsQuickActionPopoverOpen(false)}
465 onFocus={() => setIsQuickActionPopoverOpen(true)}
466 onMouseEnter={() => setIsQuickActionPopoverOpen(true)}
467 onMouseLeave={() => setIsQuickActionPopoverOpen(false)}
468 tabIndex={0}
469 />
470 }
359471 >
360 <GitActionItemIcon icon={item.icon} />
361 {item.label}
362 </MenuItem>
363 ))}
364 {gitStatus?.branch === null && (
365 <p className="px-2 py-1.5 text-xs text-warning">
366 Detached HEAD: create and checkout a branch to enable push and PR actions.
367 </p>
368 )}
369 {gitStatus &&
370 gitStatus.branch !== null &&
371 !gitStatus.hasWorkingTreeChanges &&
372 gitStatus.behindCount > 0 && (
472 <Button
473 variant="outline"
474 size="xs"
475 disabled={isGitActionRunning || quickAction.disabled}
476 className="cursor-not-allowed disabled:cursor-not-allowed"
477 onClick={runQuickAction}
478 >
479 <GitQuickActionIcon quickAction={quickAction} />
480 {quickAction.label}
481 </Button>
482 </PopoverTrigger>
483 <PopoverPopup tooltipStyle side="bottom" align="start">
484 {quickActionDisabledReason}
485 </PopoverPopup>
486 </Popover>
487 ) : (
488 <Button
489 variant="outline"
490 size="xs"
491 disabled={isGitActionRunning || quickAction.disabled}
492 onClick={runQuickAction}
493 >
494 <GitQuickActionIcon quickAction={quickAction} />
495 {quickAction.label}
496 </Button>
497 )}
498 <GroupSeparator />
499 <Menu
500 onOpenChange={(open) => {
501 if (open) void refreshGitStatus();
502 if (!open) setOpenDisabledMenuItemId(null);
503 }}
504 >
505 <MenuTrigger
506 render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />}
507 disabled={isGitActionRunning}
508 >
509 <ChevronDownIcon aria-hidden="true" className="size-4" />
510 </MenuTrigger>
511 <MenuPopup align="end" sideOffset={4}>
512 {gitActionMenuItems.map((item) => {
513 const disabledReason = getMenuActionDisabledReason(item, gitStatus, isGitActionRunning);
514 if (item.disabled && disabledReason) {
515 return (
516 <Popover
517 key={`${item.id}-${item.label}`}
518 open={openDisabledMenuItemId === item.id}
519 onOpenChange={(open) => setOpenDisabledMenuItemId(open ? item.id : null)}
520 >
521 <PopoverTrigger
522 render={
523 <span
524 className="inline-flex w-full cursor-not-allowed"
525 onBlur={() => setOpenDisabledMenuItemId(null)}
526 onFocus={() => setOpenDisabledMenuItemId(item.id)}
527 onMouseEnter={() => setOpenDisabledMenuItemId(item.id)}
528 onMouseLeave={() => setOpenDisabledMenuItemId(null)}
529 tabIndex={0}
530 />
531 }
532 >
533 <MenuItem disabled>
534 <GitActionItemIcon icon={item.icon} />
535 {item.label}
536 </MenuItem>
537 </PopoverTrigger>
538 <PopoverPopup tooltipStyle side="left" align="center">
539 {disabledReason}
540 </PopoverPopup>
541 </Popover>
542 );
543 }
544
545 return (
546 <MenuItem
547 key={`${item.id}-${item.label}`}
548 disabled={item.disabled}
549 onClick={() => {
550 openDialogForMenuItem(item);
551 }}
552 >
553 <GitActionItemIcon icon={item.icon} />
554 {item.label}
555 </MenuItem>
556 );
557 })}
558 {gitStatus?.branch === null && (
373559 <p className="px-2 py-1.5 text-xs text-warning">
374 Behind upstream. Pull/rebase first.
560 Detached HEAD: create and checkout a branch to enable push and PR actions.
375561 </p>
376562 )}
377 {gitStatusError && (
378 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>
379 )}
380 </MenuPopup>
381 </Menu>
382 </Group>
563 {gitStatus &&
564 gitStatus.branch !== null &&
565 !gitStatus.hasWorkingTreeChanges &&
566 gitStatus.behindCount > 0 && (
567 <p className="px-2 py-1.5 text-xs text-warning">
568 Behind upstream. Pull/rebase first.
569 </p>
570 )}
571 {gitStatusError && (
572 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>
573 )}
574 </MenuPopup>
575 </Menu>
576 </Group>
577 )}
383578
384579 <Dialog
385580 open={activeDialogAction !== null}
......@@ -396,19 +591,72 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
396591 <DialogDescription>{dialogDescription}</DialogDescription>
397592 </DialogHeader>
398593 <DialogPanel className="space-y-4">
399 <div className="rounded-lg border border-input bg-muted/40 p-3 text-xs">
400 <p>
401 <span className="font-medium">Branch:</span>{" "}
402 {gitStatus?.branch ?? "(detached HEAD)"}
403 </p>
404 <p>
405 <span className="font-medium">Working tree:</span>{" "}
406 {gitStatus?.hasWorkingTreeChanges ? "Has changes" : "Clean"}
407 </p>
408 <p>
409 <span className="font-medium">Ahead/behind:</span>{" "}
410 {gitStatus ? `${gitStatus.aheadCount}/${gitStatus.behindCount}` : "unknown"}
411 </p>
594 <div className="space-y-3 rounded-lg border border-input bg-muted/40 p-3 text-xs">
595 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">
596 <span className="text-muted-foreground">Branch</span>
597 <span className="font-medium">{gitStatus?.branch ?? "(detached HEAD)"}</span>
598 {!showCompactStatusCard && (
599 <>
600 <span className="text-muted-foreground">Upstream</span>
601 <span className="font-medium">
602 {gitStatus
603 ? formatUpstreamStatus({
604 hasUpstream: gitStatus.hasUpstream,
605 aheadCount: gitStatus.aheadCount,
606 behindCount: gitStatus.behindCount,
607 })
608 : "unknown"}
609 </span>
610 <span className="text-muted-foreground">Working tree</span>
611 <span className="font-medium">
612 {!gitStatus || !gitStatus.hasWorkingTreeChanges
613 ? "Clean"
614 : `${gitStatus.workingTree.files.length} file(s)`}
615 </span>
616 <span className="text-muted-foreground">Diff</span>
617 <span className="font-mono">
618 {!gitStatus || !gitStatus.hasWorkingTreeChanges ? (
619 "none"
620 ) : (
621 <>
622 <span className="text-success">+{gitStatus.workingTree.insertions}</span>
623 <span className="text-muted-foreground"> / </span>
624 <span className="text-destructive">-{gitStatus.workingTree.deletions}</span>
625 </>
626 )}
627 </span>
628 </>
629 )}
630 </div>
631 <div className="space-y-1">
632 <p className="text-muted-foreground">Files</p>
633 {!gitStatus || gitStatus.workingTree.files.length === 0 ? (
634 <p className="font-medium">none</p>
635 ) : (
636 <div className="space-y-1">
637 {gitStatus.workingTree.files.slice(0, 6).map((file) => (
638 <button
639 type="button"
640 key={file.path}
641 className="flex w-full items-center justify-between gap-3 rounded-md border border-input bg-background px-2 py-1 font-mono text-left transition-colors hover:bg-accent/50"
642 onClick={() => openChangedFileInEditor(file.path)}
643 >
644 <span className="truncate">{file.path}</span>
645 <span className="shrink-0">
646 <span className="text-success">+{file.insertions}</span>
647 <span className="text-muted-foreground"> / </span>
648 <span className="text-destructive">-{file.deletions}</span>
649 </span>
650 </button>
651 ))}
652 {gitStatus.workingTree.files.length > 6 && (
653 <Badge variant="outline" size="sm">
654 +{gitStatus.workingTree.files.length - 6} more
655 </Badge>
656 )}
657 </div>
658 )}
659 </div>
412660 </div>
413661 {activeDialogAction === "commit" && (
414662 <div className="space-y-1">
apps/web/src/components/ui/toast.tsx+4-4
......@@ -37,7 +37,7 @@ interface ToastProviderProps extends Toast.Provider.Props {
3737
3838function ToastProvider({
3939 children,
40 position = "bottom-right",
40 position = "top-right",
4141 ...props
4242}: ToastProviderProps) {
4343 return (
......@@ -48,7 +48,7 @@ function ToastProvider({
4848 );
4949}
5050
51function Toasts({ position = "bottom-right" }: { position: ToastPosition }) {
51function Toasts({ position = "top-right" }: { position: ToastPosition }) {
5252 const { toasts } = Toast.useToastManager();
5353 const isTop = position.startsWith("top");
5454
......@@ -56,9 +56,9 @@ function Toasts({ position = "bottom-right" }: { position: ToastPosition }) {
5656 <Toast.Portal data-slot="toast-portal">
5757 <Toast.Viewport
5858 className={cn(
59 "fixed z-50 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",
59 "fixed z-50 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-header-offset:52px] [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",
6060 // Vertical positioning
61 "data-[position*=top]:top-(--toast-inset)",
61 "data-[position*=top]:top-[calc(var(--toast-inset)+var(--toast-header-offset))]",
6262 "data-[position*=bottom]:bottom-(--toast-inset)",
6363 // Horizontal positioning
6464 "data-[position*=left]:left-(--toast-inset)",
apps/web/src/lib/gitReactQuery.ts created+131
......@@ -0,0 +1,131 @@
1import type { GitStackedAction, NativeApi } from "@t3tools/contracts";
2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
3
4export const gitQueryKeys = {
5 all: ["git"] as const,
6 status: (cwd: string | null) => ["git", "status", cwd] as const,
7 branches: (cwd: string | null) => ["git", "branches", cwd] as const,
8};
9
10export function invalidateGitQueries(queryClient: QueryClient) {
11 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
12}
13
14export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | null) {
15 return queryOptions({
16 queryKey: gitQueryKeys.status(cwd),
17 queryFn: async () => {
18 if (!api || !cwd) {
19 throw new Error("Git status is unavailable.");
20 }
21 return api.git.status({ cwd });
22 },
23 enabled: !!api && !!cwd,
24 });
25}
26
27export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string | null) {
28 return queryOptions({
29 queryKey: gitQueryKeys.branches(cwd),
30 queryFn: async () => {
31 if (!api || !cwd) {
32 throw new Error("Git branches are unavailable.");
33 }
34 return api.git.listBranches({ cwd });
35 },
36 enabled: !!api && !!cwd,
37 });
38}
39
40export function gitInitMutationOptions(input: {
41 api: NativeApi | undefined;
42 cwd: string | null;
43 queryClient: QueryClient;
44}) {
45 return mutationOptions({
46 mutationFn: async () => {
47 if (!input.api || !input.cwd) throw new Error("Git init is unavailable.");
48 return input.api.git.init({ cwd: input.cwd });
49 },
50 onSuccess: async () => {
51 await invalidateGitQueries(input.queryClient);
52 },
53 });
54}
55
56export function gitCheckoutMutationOptions(input: {
57 api: NativeApi | undefined;
58 cwd: string | null;
59 queryClient: QueryClient;
60}) {
61 return mutationOptions({
62 mutationFn: async (branch: string) => {
63 if (!input.api || !input.cwd) throw new Error("Git checkout is unavailable.");
64 return input.api.git.checkout({ cwd: input.cwd, branch });
65 },
66 onSuccess: async () => {
67 await invalidateGitQueries(input.queryClient);
68 },
69 });
70}
71
72export function gitCreateBranchAndCheckoutMutationOptions(input: {
73 api: NativeApi | undefined;
74 cwd: string | null;
75 queryClient: QueryClient;
76}) {
77 return mutationOptions({
78 mutationFn: async (branch: string) => {
79 if (!input.api || !input.cwd) throw new Error("Git branch creation is unavailable.");
80 await input.api.git.createBranch({ cwd: input.cwd, branch });
81 return input.api.git.checkout({ cwd: input.cwd, branch });
82 },
83 onSuccess: async () => {
84 await invalidateGitQueries(input.queryClient);
85 },
86 });
87}
88
89export function gitRunStackedActionMutationOptions(input: {
90 api: NativeApi | undefined;
91 cwd: string | null;
92 queryClient: QueryClient;
93}) {
94 return mutationOptions({
95 mutationFn: async ({
96 action,
97 commitMessage,
98 }: {
99 action: GitStackedAction;
100 commitMessage?: string;
101 }) => {
102 if (!input.api || !input.cwd) {
103 throw new Error("Git action is unavailable.");
104 }
105 return input.api.git.runStackedAction({
106 cwd: input.cwd,
107 action,
108 ...(commitMessage ? { commitMessage } : {}),
109 });
110 },
111 onSuccess: async () => {
112 await invalidateGitQueries(input.queryClient);
113 },
114 });
115}
116
117export function gitPullMutationOptions(input: {
118 api: NativeApi | undefined;
119 cwd: string | null;
120 queryClient: QueryClient;
121}) {
122 return mutationOptions({
123 mutationFn: async () => {
124 if (!input.api || !input.cwd) throw new Error("Git pull is unavailable.");
125 return input.api.git.pull({ cwd: input.cwd });
126 },
127 onSuccess: async () => {
128 await invalidateGitQueries(input.queryClient);
129 },
130 });
131}
packages/contracts/src/git.test.ts+10
......@@ -25,6 +25,14 @@ describe("git contracts", () => {
2525 const parsed = gitStatusResultSchema.parse({
2626 branch: "feature/git-actions",
2727 hasWorkingTreeChanges: true,
28 workingTree: {
29 files: [
30 { path: "src/git.ts", insertions: 8, deletions: 2 },
31 { path: "src/git.test.ts", insertions: 4, deletions: 1 },
32 ],
33 insertions: 12,
34 deletions: 3,
35 },
2836 hasUpstream: false,
2937 aheadCount: 0,
3038 behindCount: 0,
......@@ -32,6 +40,8 @@ describe("git contracts", () => {
3240 });
3341 expect(parsed.branch).toBe("feature/git-actions");
3442 expect(parsed.hasWorkingTreeChanges).toBe(true);
43 expect(parsed.workingTree.files[0]?.insertions).toBe(8);
44 expect(parsed.workingTree.files).toHaveLength(2);
3545 });
3646
3747 it("accepts supported stacked actions", () => {
packages/contracts/src/git.ts+11
......@@ -75,6 +75,17 @@ export const gitPullInputSchema = z.object({
7575export const gitStatusResultSchema = z.object({
7676 branch: z.string().min(1).nullable(),
7777 hasWorkingTreeChanges: z.boolean(),
78 workingTree: z.object({
79 files: z.array(
80 z.object({
81 path: z.string().min(1),
82 insertions: z.number().int().nonnegative(),
83 deletions: z.number().int().nonnegative(),
84 }),
85 ),
86 insertions: z.number().int().nonnegative(),
87 deletions: z.number().int().nonnegative(),
88 }),
7889 hasUpstream: z.boolean(),
7990 aheadCount: z.number().int().nonnegative(),
8091 behindCount: z.number().int().nonnegative(),