authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 18:43:24-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 19:22:47-08:00
log1b98213d6d93df663b91774a5f18e94a9a1906af
tree80c262933eb2b0df1a964aaac5ea80ffcc4f6f05
parent0d67424f6945fb451e9b7639861cdac8b18a0c3b

Scope git mutation state by cwd for action loading UI

- add `gitMutationKeys` and attach mutation keys across git mutation options - switch GitActionsControl loading state to `useIsMutating` with cwd-scoped keys - add tests for cwd-scoped mutation keys and mutation option wiring

3 files changed, 56 insertions(+), 2 deletions(-)

apps/web/src/components/GitActionsControl.tsx+6-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts";1import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";2import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useCallback, useEffect, useMemo, useState } from "react";3import { useCallback, useEffect, useMemo, useState } from "react";
4import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";4import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
5import { GitHubIcon } from "./Icons";5import { GitHubIcon } from "./Icons";
...@@ -33,6 +33,7 @@ import { toastManager } from "~/components/ui/toast";...@@ -33,6 +33,7 @@ import { toastManager } from "~/components/ui/toast";
33import {33import {
34 gitBranchesQueryOptions,34 gitBranchesQueryOptions,
35 gitInitMutationOptions,35 gitInitMutationOptions,
36 gitMutationKeys,
36 gitPullMutationOptions,37 gitPullMutationOptions,
37 gitRunStackedActionMutationOptions,38 gitRunStackedActionMutationOptions,
38 gitStatusQueryOptions,39 gitStatusQueryOptions,
...@@ -168,7 +169,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -168,7 +169,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
168 );169 );
169 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));170 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
170171
171 const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending;172 const isRunStackedActionRunning =
173 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;
174 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
175 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;
172 const isDefaultBranch = useMemo(() => {176 const isDefaultBranch = useMemo(() => {
173 const branchName = gitStatusForActions?.branch;177 const branchName = gitStatusForActions?.branch;
174 if (!branchName) return false;178 if (!branchName) return false;
apps/web/src/lib/gitReactQuery.test.ts created+33
...@@ -0,0 +1,33 @@
1import { QueryClient } from "@tanstack/react-query";
2import { describe, expect, it } from "vitest";
3import {
4 gitMutationKeys,
5 gitPullMutationOptions,
6 gitRunStackedActionMutationOptions,
7} from "./gitReactQuery";
8
9describe("gitMutationKeys", () => {
10 it("scopes stacked action keys by cwd", () => {
11 expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual(
12 gitMutationKeys.runStackedAction("/repo/b"),
13 );
14 });
15
16 it("scopes pull keys by cwd", () => {
17 expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b"));
18 });
19});
20
21describe("git mutation options", () => {
22 const queryClient = new QueryClient();
23
24 it("attaches cwd-scoped mutation key for runStackedAction", () => {
25 const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient });
26 expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a"));
27 });
28
29 it("attaches cwd-scoped mutation key for pull", () => {
30 const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient });
31 expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a"));
32 });
33});
apps/web/src/lib/gitReactQuery.ts+17
...@@ -13,6 +13,16 @@ export const gitQueryKeys = {...@@ -13,6 +13,16 @@ export const gitQueryKeys = {
13 branches: (cwd: string | null) => ["git", "branches", cwd] as const,13 branches: (cwd: string | null) => ["git", "branches", cwd] as const,
14};14};
1515
16export const gitMutationKeys = {
17 init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const,
18 checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const,
19 createBranchAndCheckout: (cwd: string | null) =>
20 ["git", "mutation", "create-branch-and-checkout", cwd] as const,
21 runStackedAction: (cwd: string | null) =>
22 ["git", "mutation", "run-stacked-action", cwd] as const,
23 pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,
24};
25
16export function invalidateGitQueries(queryClient: QueryClient) {26export function invalidateGitQueries(queryClient: QueryClient) {
17 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });27 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
18}28}
...@@ -51,6 +61,7 @@ export function gitBranchesQueryOptions(cwd: string | null) {...@@ -51,6 +61,7 @@ export function gitBranchesQueryOptions(cwd: string | null) {
5161
52export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {62export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
53 return mutationOptions({63 return mutationOptions({
64 mutationKey: gitMutationKeys.init(input.cwd),
54 mutationFn: async () => {65 mutationFn: async () => {
55 const api = ensureNativeApi();66 const api = ensureNativeApi();
56 if (!input.cwd) throw new Error("Git init is unavailable.");67 if (!input.cwd) throw new Error("Git init is unavailable.");
...@@ -67,6 +78,7 @@ export function gitCheckoutMutationOptions(input: {...@@ -67,6 +78,7 @@ export function gitCheckoutMutationOptions(input: {
67 queryClient: QueryClient;78 queryClient: QueryClient;
68}) {79}) {
69 return mutationOptions({80 return mutationOptions({
81 mutationKey: gitMutationKeys.checkout(input.cwd),
70 mutationFn: async (branch: string) => {82 mutationFn: async (branch: string) => {
71 const api = ensureNativeApi();83 const api = ensureNativeApi();
72 if (!input.cwd) throw new Error("Git checkout is unavailable.");84 if (!input.cwd) throw new Error("Git checkout is unavailable.");
...@@ -83,6 +95,7 @@ export function gitCreateBranchAndCheckoutMutationOptions(input: {...@@ -83,6 +95,7 @@ export function gitCreateBranchAndCheckoutMutationOptions(input: {
83 queryClient: QueryClient;95 queryClient: QueryClient;
84}) {96}) {
85 return mutationOptions({97 return mutationOptions({
98 mutationKey: gitMutationKeys.createBranchAndCheckout(input.cwd),
86 mutationFn: async (branch: string) => {99 mutationFn: async (branch: string) => {
87 const api = ensureNativeApi();100 const api = ensureNativeApi();
88 if (!input.cwd) throw new Error("Git branch creation is unavailable.");101 if (!input.cwd) throw new Error("Git branch creation is unavailable.");
...@@ -100,6 +113,7 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -100,6 +113,7 @@ export function gitRunStackedActionMutationOptions(input: {
100 queryClient: QueryClient;113 queryClient: QueryClient;
101}) {114}) {
102 return mutationOptions({115 return mutationOptions({
116 mutationKey: gitMutationKeys.runStackedAction(input.cwd),
103 mutationFn: async ({117 mutationFn: async ({
104 action,118 action,
105 commitMessage,119 commitMessage,
...@@ -123,6 +137,7 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -123,6 +137,7 @@ export function gitRunStackedActionMutationOptions(input: {
123137
124export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {138export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
125 return mutationOptions({139 return mutationOptions({
140 mutationKey: gitMutationKeys.pull(input.cwd),
126 mutationFn: async () => {141 mutationFn: async () => {
127 const api = ensureNativeApi();142 const api = ensureNativeApi();
128 if (!input.cwd) throw new Error("Git pull is unavailable.");143 if (!input.cwd) throw new Error("Git pull is unavailable.");
...@@ -151,6 +166,7 @@ export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClie...@@ -151,6 +166,7 @@ export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClie
151 if (!cwd) throw new Error("Git worktree creation is unavailable.");166 if (!cwd) throw new Error("Git worktree creation is unavailable.");
152 return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null });167 return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null });
153 },168 },
169 mutationKey: ["git", "mutation", "create-worktree"] as const,
154 onSettled: async () => {170 onSettled: async () => {
155 await invalidateGitQueries(input.queryClient);171 await invalidateGitQueries(input.queryClient);
156 },172 },
...@@ -164,6 +180,7 @@ export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClie...@@ -164,6 +180,7 @@ export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClie
164 if (!cwd) throw new Error("Git worktree removal is unavailable.");180 if (!cwd) throw new Error("Git worktree removal is unavailable.");
165 return api.git.removeWorktree({ cwd, path, force });181 return api.git.removeWorktree({ cwd, path, force });
166 },182 },
183 mutationKey: ["git", "mutation", "remove-worktree"] as const,
167 onSettled: async () => {184 onSettled: async () => {
168 await invalidateGitQueries(input.queryClient);185 await invalidateGitQueries(input.queryClient);
169 },186 },