History

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

Julius Marminge committed 9 months ago

Commit 85eb3791 parent 5a3059412 files · +871 −353Browse files

Unified · Split

12 files
  1. apps/server/src/git.ts +85 −2
  2. apps/server/src/gitManager.ts +1 −0
  3. apps/server/src/wsServer.test.ts +10 −0
  4. apps/web/src/components/BranchToolbar.tsx +118 −152
  5. apps/web/src/components/ChatView.tsx +8 −3
  6. apps/web/src/components/GitActionsControl.logic.test.ts +73 −18
  7. apps/web/src/components/GitActionsControl.logic.ts +34 −36
  8. apps/web/src/components/GitActionsControl.tsx +386 −138
  9. apps/web/src/components/ui/toast.tsx +4 −4
  10. apps/web/src/lib/gitReactQuery.ts +131 −0
  11. packages/contracts/src/git.test.ts +10 −0
  12. packages/contracts/src/git.ts +11 −0
apps/server/src/git.ts+85−2
@@ -94,6 +94,51 @@ function parseBranchAb(value: string): { ahead: number; behind: number } { · 93 unmodified lines
9494 };
9595 }
9696
97+function 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+
120+function 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+
97142 function commandLabel(args: readonly string[]): string {
98143 return `git ${args.join(" ")}`;
99144 }
@@ -176,6 +221,7 @@ export class GitCoreService { · 76 unmodified lines
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 { · 2 unmodified lines
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 { · 13 unmodified lines
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−0
@@ -218,6 +218,7 @@ export class GitManager { · 217 unmodified lines
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−0
@@ -557,6 +557,11 @@ describe("WebSocket Server", () => { · 556 unmodified lines
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", () => { · 18 unmodified lines
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"; · 2 unmodified lines
33 import { useEffect, useState } from "react";
44
55 import { useNativeApi } from "../hooks/useNativeApi";
6+import {
7+ gitBranchesQueryOptions,
8+ gitCheckoutMutationOptions,
9+ gitCreateBranchAndCheckoutMutationOptions,
10+} from "../lib/gitReactQuery";
611 import { useStore } from "../store";
712 import { Button } from "./ui/button";
813 import {
@@ -37,28 +42,19 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B · 28 unmodified lines
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 · 1 unmodified line
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 · 103 unmodified lines
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 { · 18 unmodified lines
1919 useRef,
2020 useState,
2121 } from "react";
22+import { useQuery } from "@tanstack/react-query";
23+import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
2224
2325 import { EDITORS, type EditorId } from "@t3tools/contracts";
2426 import { isElectron } from "../env";
@@ -244,6 +246,9 @@ export default function ChatView() { · 219 unmodified lines
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() { · 873 unmodified lines
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() { · 169 unmodified lines
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() { · 130 unmodified lines
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 { · 9 unmodified lines
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", () => { · 65 unmodified lines
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", () => { · 35 unmodified lines
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", () => { · 184 unmodified lines
314319 });
315320
316321 describe("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", () => { · 27 unmodified lines
350380 });
351381 });
352382
383+describe("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+
353405 describe("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", () => { · 33 unmodified lines
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", () => { · 28 unmodified lines
428484 });
429485
430486 describe("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", () => { · 9 unmodified lines
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", () => { · 20 unmodified lines
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", () => { · 7 unmodified lines
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( · 60 unmodified lines
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( · 43 unmodified lines
111111 export 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( · 3 unmodified lines
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( · 1 unmodified line
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( · 4 unmodified lines
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
190188 export function requiresDefaultBranchConfirmation(
apps/web/src/components/GitActionsControl.tsx+386−138
@@ -1,8 +1,9 @@
11 import {
2+ type GitStatusResult,
23 type GitStackedAction,
34 type NativeApi,
45 } from "@t3tools/contracts";
5−import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
67 import { useCallback, useMemo, useState } from "react";
78 import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
89 import { GitHubIcon } from "./Icons";
@@ -16,6 +17,7 @@ import { · 7 unmodified lines
1617 requiresDefaultBranchConfirmation,
1718 resolveQuickAction,
1819 } from "./GitActionsControl.logic";
20+import { Badge } from "~/components/ui/badge";
1921 import { Button } from "~/components/ui/button";
2022 import {
2123 Dialog,
@@ -28,14 +30,97 @@ import { · 6 unmodified lines
2830 } from "~/components/ui/dialog";
2931 import { Group, GroupSeparator } from "~/components/ui/group";
3032 import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
33+import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
3134 import { Textarea } from "~/components/ui/textarea";
3235 import { toastManager } from "~/components/ui/toast";
36+import {
37+ gitBranchesQueryOptions,
38+ gitInitMutationOptions,
39+ gitPullMutationOptions,
40+ gitRunStackedActionMutationOptions,
41+ gitStatusQueryOptions,
42+ invalidateGitQueries,
43+} from "~/lib/gitReactQuery";
44+import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
3345
3446 interface GitActionsControlProps {
3547 api: NativeApi | undefined;
3648 gitCwd: string | null;
3749 }
3850
51+function 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+
110+function 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+
39124 function 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 }) { · 12 unmodified lines
54139 return <InfoIcon className="size-4" />;
55140 }
56141
57−function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] {
58− return ["git", "status", gitCwd];
59−}
60−
61−function 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− });
142+function 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
74158 export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
@@ -80,41 +164,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp · 5 unmodified lines
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 · 8 unmodified lines
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 · 42 unmodified lines
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 · 1 unmodified line
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 · 72 unmodified lines
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 · 6 unmodified lines
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 · 10 unmodified lines
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 { · 36 unmodified lines
3737
3838 function ToastProvider({
3939 children,
40− position = "bottom-right",
40+ position = "top-right",
4141 ...props
4242 }: ToastProviderProps) {
4343 return (
@@ -48,7 +48,7 @@ function ToastProvider({ · 4 unmodified lines
4848 );
4949 }
5050
51−function Toasts({ position = "bottom-right" }: { position: ToastPosition }) {
51+function 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 }) { · 1 unmodified line
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+131−0
@@ -0,0 +1,131 @@
1+import type { GitStackedAction, NativeApi } from "@t3tools/contracts";
2+import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
3+
4+export 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+
10+export function invalidateGitQueries(queryClient: QueryClient) {
11+ return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
12+}
13+
14+export 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+
27+export 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+
40+export 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+
56+export 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+
72+export 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+
89+export 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+
117+export 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−0
@@ -25,6 +25,14 @@ describe("git contracts", () => { · 24 unmodified lines
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", () => { · 1 unmodified line
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−0
@@ -75,6 +75,17 @@ export const gitPullInputSchema = z.object({ · 74 unmodified lines
7575 export 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(),