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 @@...@@ -1,13 +1,9 @@
1import type { GitBranch, ThreadId } from "@t3tools/contracts";1import type { GitBranch, ThreadId } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";2import { useQuery, useQueryClient } from "@tanstack/react-query";
3import { startTransition, useOptimistic, useState } from "react";3import { useOptimistic, useState, useTransition } from "react";
44
5import { newCommandId } from "../lib/utils";5import { newCommandId } from "../lib/utils";
6import {6import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery";
7 gitBranchesQueryOptions,
8 gitCheckoutMutationOptions,
9 gitCreateBranchAndCheckoutMutationOptions,
10} from "../lib/gitReactQuery";
11import { readNativeApi } from "../nativeApi";7import { readNativeApi } from "../nativeApi";
12import { useComposerDraftStore } from "../composerDraftStore";8import { useComposerDraftStore } from "../composerDraftStore";
13import { useStore } from "../store";9import { useStore } from "../store";
...@@ -26,6 +22,7 @@ import {...@@ -26,6 +22,7 @@ import {
26 ComboboxTrigger,22 ComboboxTrigger,
27} from "./ui/combobox";23} from "./ui/combobox";
28import { ChevronDownIcon } from "lucide-react";24import { ChevronDownIcon } from "lucide-react";
25import { toastManager } from "./ui/toast";
2926
30interface BranchToolbarProps {27interface BranchToolbarProps {
31 threadId: ThreadId;28 threadId: ThreadId;
...@@ -76,15 +73,6 @@ export default function BranchToolbar({...@@ -76,15 +73,6 @@ export default function BranchToolbar({
76 effectiveEnvMode === "worktree" && !activeWorktreePath73 effectiveEnvMode === "worktree" && !activeWorktreePath
77 ? activeThreadBranch74 ? activeThreadBranch
78 : (currentGitBranch ?? activeThreadBranch);75 : (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 };
88 const branchNames = branches.map((branch) => branch.name);76 const branchNames = branches.map((branch) => branch.name);
89 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));77 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));
90 const trimmedBranchQuery = branchQuery.trim();78 const trimmedBranchQuery = branchQuery.trim();
...@@ -97,15 +85,20 @@ export default function BranchToolbar({...@@ -97,15 +85,20 @@ export default function BranchToolbar({
97 createBranchItemValue && !hasExactBranchMatch85 createBranchItemValue && !hasExactBranchMatch
98 ? [...branchNames, createBranchItemValue]86 ? [...branchNames, createBranchItemValue]
99 : branchNames;87 : branchNames;
100 // ── Mutations ─────────────────────────────────────────────────────────
10188
102 const checkoutMutation = useMutation(gitCheckoutMutationOptions({ cwd: branchCwd, queryClient }));89 // ── Helpers ───────────────────────────────────────────────────────────
10390
104 const createBranchMutation = useMutation(91 const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(
105 gitCreateBranchAndCheckoutMutationOptions({ cwd: branchCwd, queryClient }),92 canonicalActiveBranch,
93 (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,
106 );94 );
10795 const [isBranchActionPending, startBranchActionTransition] = useTransition();
108 // ── Helpers ───────────────────────────────────────────────────────────96 const runBranchAction = (action: () => Promise<void>) => {
97 startBranchActionTransition(async () => {
98 await action().catch(() => undefined);
99 await invalidateGitQueries(queryClient).catch(() => undefined);
100 });
101 };
109102
110 const setThreadError = (error: string | null) => {103 const setThreadError = (error: string | null) => {
111 if (!activeThreadId) return;104 if (!activeThreadId) return;
...@@ -150,11 +143,10 @@ export default function BranchToolbar({...@@ -150,11 +143,10 @@ export default function BranchToolbar({
150143
151 const selectBranch = (branch: GitBranch) => {144 const selectBranch = (branch: GitBranch) => {
152 const api = readNativeApi();145 const api = readNativeApi();
153 if (!api || !activeThreadId || !branchCwd) return;146 if (!api || !activeThreadId || !branchCwd || isBranchActionPending) return;
154147
155 // For new worktree mode, selecting a branch picks the base branch.148 // For new worktree mode, selecting a branch picks the base branch.
156 if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) {149 if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) {
157 setThreadError(null);
158 setThreadBranch(branch.name, null);150 setThreadBranch(branch.name, null);
159 setIsBranchMenuOpen(false);151 setIsBranchMenuOpen(false);
160 onComposerFocusRequest?.();152 onComposerFocusRequest?.();
...@@ -165,7 +157,6 @@ export default function BranchToolbar({...@@ -165,7 +157,6 @@ export default function BranchToolbar({
165 // trying to checkout (which git would reject with "already used by worktree").157 // trying to checkout (which git would reject with "already used by worktree").
166 if (branch.worktreePath) {158 if (branch.worktreePath) {
167 const isMainWorktree = branch.worktreePath === activeProject?.cwd;159 const isMainWorktree = branch.worktreePath === activeProject?.cwd;
168 setThreadError(null);
169 // Main worktree → switch back to local (project cwd, worktreePath=null).160 // Main worktree → switch back to local (project cwd, worktreePath=null).
170 // Secondary worktree → point the thread at that worktree path.161 // Secondary worktree → point the thread at that worktree path.
171 setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath);162 setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath);
...@@ -177,49 +168,69 @@ export default function BranchToolbar({...@@ -177,49 +168,69 @@ export default function BranchToolbar({
177 const selectedBranchName = branch.isRemote168 const selectedBranchName = branch.isRemote
178 ? deriveLocalBranchNameFromRemoteRef(branch.name)169 ? deriveLocalBranchNameFromRemoteRef(branch.name)
179 : branch.name;170 : branch.name;
180 setOptimisticBranchInTransition(selectedBranchName);
181171
182 checkoutMutation.mutate(branch.name, {172 setIsBranchMenuOpen(false);
183 onSuccess: async () => {173 onComposerFocusRequest?.();
184 setThreadError(null);174
185 let nextBranchName = selectedBranchName;175 runBranchAction(async () => {
186 if (branch.isRemote) {176 setOptimisticBranch(selectedBranchName);
187 const status = await api.git.status({ cwd: branchCwd }).catch(() => null);177 try {
188 if (status?.branch) {178 await api.git.checkout({ cwd: branchCwd, branch: branch.name });
189 nextBranchName = status.branch;179 await invalidateGitQueries(queryClient);
190 }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;
191 }193 }
192 setOptimisticBranchInTransition(nextBranchName);194 }
193 setThreadBranch(nextBranchName, activeWorktreePath);195
194 setIsBranchMenuOpen(false);196 setOptimisticBranch(nextBranchName);
195 onComposerFocusRequest?.();197 setThreadBranch(nextBranchName, activeWorktreePath);
196 },
197 onError: (error) => {
198 setOptimisticBranchInTransition(canonicalActiveBranch);
199 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
200 setIsBranchMenuOpen(true);
201 },
202 });198 });
203 };199 };
204200
205 const createBranch = (rawName: string) => {201 const createBranch = (rawName: string) => {
206 const name = rawName.trim();202 const name = rawName.trim();
207 const api = readNativeApi();203 const api = readNativeApi();
208 if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return;204 if (!api || !activeThreadId || !branchCwd || !name || isBranchActionPending) return;
209 setOptimisticBranchInTransition(name);205
210 createBranchMutation.mutate(name, {206 setIsBranchMenuOpen(false);
211 onSuccess: () => {207 onComposerFocusRequest?.();
212 setOptimisticBranchInTransition(name);208
213 setThreadError(null);209 runBranchAction(async () => {
214 setThreadBranch(name, activeWorktreePath);210 setOptimisticBranch(name);
215 setBranchQuery("");211
216 setIsBranchMenuOpen(false);212 try {
217 onComposerFocusRequest?.();213 await api.git.createBranch({ cwd: branchCwd, branch: name });
218 },214 try {
219 onError: (error) => {215 await api.git.checkout({ cwd: branchCwd, branch: name });
220 setOptimisticBranchInTransition(canonicalActiveBranch);216 } catch (error) {
221 setThreadError(error instanceof Error ? error.message : "Failed to create branch.");217 toastManager.add({
222 },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("");
223 });234 });
224 };235 };
225236
...@@ -250,9 +261,8 @@ export default function BranchToolbar({...@@ -250,9 +261,8 @@ export default function BranchToolbar({
250 autoHighlight261 autoHighlight
251 onOpenChange={(open) => {262 onOpenChange={(open) => {
252 setIsBranchMenuOpen(open);263 setIsBranchMenuOpen(open);
253 if (!open) {264 if (!open) setBranchQuery("");
254 setBranchQuery("");265 else void invalidateGitQueries(queryClient);
255 }
256 }}266 }}
257 open={isBranchMenuOpen}267 open={isBranchMenuOpen}
258 value={resolvedActiveBranch}268 value={resolvedActiveBranch}
...@@ -260,7 +270,7 @@ export default function BranchToolbar({...@@ -260,7 +270,7 @@ export default function BranchToolbar({
260 <ComboboxTrigger270 <ComboboxTrigger
261 render={<Button variant="ghost" size="xs" />}271 render={<Button variant="ghost" size="xs" />}
262 className="text-muted-foreground/70 hover:text-foreground/80"272 className="text-muted-foreground/70 hover:text-foreground/80"
263 disabled={branchesQuery.isLoading}273 disabled={branchesQuery.isLoading || isBranchActionPending}
264 >274 >
265 <span className="max-w-[240px] truncate">275 <span className="max-w-[240px] truncate">
266 {resolvedActiveBranch276 {resolvedActiveBranch