authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 17:02:52-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 18:53:21-08:00
loga638978b75d42e78daad25170098efdec7d76dd4
treeb6f7fcd7097c94627bbdc55e6980cc0e9902ce0d
parent4a08686e749cca58dbab30307ab8445a3d5bef33

Refactor branch toolbar actions to use transition-based git ops

- replace React Query mutations with direct git API calls wrapped in `useTransition` - show toast errors for branch create/checkout failures and keep optimistic branch updates - disable branch controls during pending actions and refresh branches when opening menu

1 files changed, 74 insertions(+), 64 deletions(-)

apps/web/src/components/BranchToolbar.tsx+74-64
......@@ -1,13 +1,9 @@
11import type { GitBranch, ThreadId } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { startTransition, useOptimistic, useState } from "react";
2import { useQuery, useQueryClient } from "@tanstack/react-query";
3import { useOptimistic, useState, useTransition } from "react";
44
55import { newCommandId } from "../lib/utils";
6import {
7 gitBranchesQueryOptions,
8 gitCheckoutMutationOptions,
9 gitCreateBranchAndCheckoutMutationOptions,
10} from "../lib/gitReactQuery";
6import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery";
117import { readNativeApi } from "../nativeApi";
128import { useComposerDraftStore } from "../composerDraftStore";
139import { useStore } from "../store";
......@@ -26,6 +22,7 @@ import {
2622 ComboboxTrigger,
2723} from "./ui/combobox";
2824import { ChevronDownIcon } from "lucide-react";
25import { toastManager } from "./ui/toast";
2926
3027interface BranchToolbarProps {
3128 threadId: ThreadId;
......@@ -76,15 +73,6 @@ export default function BranchToolbar({
7673 effectiveEnvMode === "worktree" && !activeWorktreePath
7774 ? activeThreadBranch
7875 : (currentGitBranch ?? activeThreadBranch);
79 const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(
80 canonicalActiveBranch,
81 (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,
82 );
83 const setOptimisticBranchInTransition = (branch: string | null) => {
84 startTransition(() => {
85 setOptimisticBranch(branch);
86 });
87 };
8876 const branchNames = branches.map((branch) => branch.name);
8977 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));
9078 const trimmedBranchQuery = branchQuery.trim();
......@@ -97,15 +85,20 @@ export default function BranchToolbar({
9785 createBranchItemValue && !hasExactBranchMatch
9886 ? [...branchNames, createBranchItemValue]
9987 : branchNames;
100 // ── Mutations ─────────────────────────────────────────────────────────
10188
102 const checkoutMutation = useMutation(gitCheckoutMutationOptions({ cwd: branchCwd, queryClient }));
89 // ── Helpers ───────────────────────────────────────────────────────────
10390
104 const createBranchMutation = useMutation(
105 gitCreateBranchAndCheckoutMutationOptions({ cwd: branchCwd, queryClient }),
91 const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(
92 canonicalActiveBranch,
93 (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,
10694 );
107
108 // ── Helpers ───────────────────────────────────────────────────────────
95 const [isBranchActionPending, startBranchActionTransition] = useTransition();
96 const runBranchAction = (action: () => Promise<void>) => {
97 startBranchActionTransition(async () => {
98 await action().catch(() => undefined);
99 await invalidateGitQueries(queryClient).catch(() => undefined);
100 });
101 };
109102
110103 const setThreadError = (error: string | null) => {
111104 if (!activeThreadId) return;
......@@ -150,11 +143,10 @@ export default function BranchToolbar({
150143
151144 const selectBranch = (branch: GitBranch) => {
152145 const api = readNativeApi();
153 if (!api || !activeThreadId || !branchCwd) return;
146 if (!api || !activeThreadId || !branchCwd || isBranchActionPending) return;
154147
155148 // For new worktree mode, selecting a branch picks the base branch.
156149 if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) {
157 setThreadError(null);
158150 setThreadBranch(branch.name, null);
159151 setIsBranchMenuOpen(false);
160152 onComposerFocusRequest?.();
......@@ -165,7 +157,6 @@ export default function BranchToolbar({
165157 // trying to checkout (which git would reject with "already used by worktree").
166158 if (branch.worktreePath) {
167159 const isMainWorktree = branch.worktreePath === activeProject?.cwd;
168 setThreadError(null);
169160 // Main worktree → switch back to local (project cwd, worktreePath=null).
170161 // Secondary worktree → point the thread at that worktree path.
171162 setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath);
......@@ -177,49 +168,69 @@ export default function BranchToolbar({
177168 const selectedBranchName = branch.isRemote
178169 ? deriveLocalBranchNameFromRemoteRef(branch.name)
179170 : branch.name;
180 setOptimisticBranchInTransition(selectedBranchName);
181171
182 checkoutMutation.mutate(branch.name, {
183 onSuccess: async () => {
184 setThreadError(null);
185 let nextBranchName = selectedBranchName;
186 if (branch.isRemote) {
187 const status = await api.git.status({ cwd: branchCwd }).catch(() => null);
188 if (status?.branch) {
189 nextBranchName = status.branch;
190 }
172 setIsBranchMenuOpen(false);
173 onComposerFocusRequest?.();
174
175 runBranchAction(async () => {
176 setOptimisticBranch(selectedBranchName);
177 try {
178 await api.git.checkout({ cwd: branchCwd, branch: branch.name });
179 await invalidateGitQueries(queryClient);
180 } catch (error) {
181 toastManager.add({
182 type: "error",
183 title: "Failed to checkout branch.",
184 description: error instanceof Error ? error.message : "An error occurred.",
185 });
186 }
187
188 let nextBranchName = selectedBranchName;
189 if (branch.isRemote) {
190 const status = await api.git.status({ cwd: branchCwd }).catch(() => null);
191 if (status?.branch) {
192 nextBranchName = status.branch;
191193 }
192 setOptimisticBranchInTransition(nextBranchName);
193 setThreadBranch(nextBranchName, activeWorktreePath);
194 setIsBranchMenuOpen(false);
195 onComposerFocusRequest?.();
196 },
197 onError: (error) => {
198 setOptimisticBranchInTransition(canonicalActiveBranch);
199 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
200 setIsBranchMenuOpen(true);
201 },
194 }
195
196 setOptimisticBranch(nextBranchName);
197 setThreadBranch(nextBranchName, activeWorktreePath);
202198 });
203199 };
204200
205201 const createBranch = (rawName: string) => {
206202 const name = rawName.trim();
207203 const api = readNativeApi();
208 if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return;
209 setOptimisticBranchInTransition(name);
210 createBranchMutation.mutate(name, {
211 onSuccess: () => {
212 setOptimisticBranchInTransition(name);
213 setThreadError(null);
214 setThreadBranch(name, activeWorktreePath);
215 setBranchQuery("");
216 setIsBranchMenuOpen(false);
217 onComposerFocusRequest?.();
218 },
219 onError: (error) => {
220 setOptimisticBranchInTransition(canonicalActiveBranch);
221 setThreadError(error instanceof Error ? error.message : "Failed to create branch.");
222 },
204 if (!api || !activeThreadId || !branchCwd || !name || isBranchActionPending) return;
205
206 setIsBranchMenuOpen(false);
207 onComposerFocusRequest?.();
208
209 runBranchAction(async () => {
210 setOptimisticBranch(name);
211
212 try {
213 await api.git.createBranch({ cwd: branchCwd, branch: name });
214 try {
215 await api.git.checkout({ cwd: branchCwd, branch: name });
216 } catch (error) {
217 toastManager.add({
218 type: "error",
219 title: "Failed to checkout branch.",
220 description: error instanceof Error ? error.message : "An error occurred.",
221 });
222 }
223 } catch (error) {
224 toastManager.add({
225 type: "error",
226 title: "Failed to create branch.",
227 description: error instanceof Error ? error.message : "An error occurred.",
228 });
229 }
230
231 setOptimisticBranch(name);
232 setThreadBranch(name, activeWorktreePath);
233 setBranchQuery("");
223234 });
224235 };
225236
......@@ -250,9 +261,8 @@ export default function BranchToolbar({
250261 autoHighlight
251262 onOpenChange={(open) => {
252263 setIsBranchMenuOpen(open);
253 if (!open) {
254 setBranchQuery("");
255 }
264 if (!open) setBranchQuery("");
265 else void invalidateGitQueries(queryClient);
256266 }}
257267 open={isBranchMenuOpen}
258268 value={resolvedActiveBranch}
......@@ -260,7 +270,7 @@ export default function BranchToolbar({
260270 <ComboboxTrigger
261271 render={<Button variant="ghost" size="xs" />}
262272 className="text-muted-foreground/70 hover:text-foreground/80"
263 disabled={branchesQuery.isLoading}
273 disabled={branchesQuery.isLoading || isBranchActionPending}
264274 >
265275 <span className="max-w-[240px] truncate">
266276 {resolvedActiveBranch