| ... | ... | @@ -1,13 +1,9 @@ |
| 1 | 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; |
| 2 | | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | | import { startTransition, useOptimistic, useState } from "react"; |
| 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useOptimistic, useState, useTransition } from "react"; |
| 4 | 4 | |
| 5 | 5 | import { newCommandId } from "../lib/utils"; |
| 6 | | import { |
| 7 | | gitBranchesQueryOptions, |
| 8 | | gitCheckoutMutationOptions, |
| 9 | | gitCreateBranchAndCheckoutMutationOptions, |
| 10 | | } from "../lib/gitReactQuery"; |
| 6 | import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery"; |
| 11 | 7 | import { readNativeApi } from "../nativeApi"; |
| 12 | 8 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 13 | 9 | import { useStore } from "../store"; |
| ... | ... | @@ -26,6 +22,7 @@ import { |
| 26 | 22 | ComboboxTrigger, |
| 27 | 23 | } from "./ui/combobox"; |
| 28 | 24 | import { ChevronDownIcon } from "lucide-react"; |
| 25 | import { toastManager } from "./ui/toast"; |
| 29 | 26 | |
| 30 | 27 | interface BranchToolbarProps { |
| 31 | 28 | threadId: ThreadId; |
| ... | ... | @@ -76,15 +73,6 @@ export default function BranchToolbar({ |
| 76 | 73 | effectiveEnvMode === "worktree" && !activeWorktreePath |
| 77 | 74 | ? activeThreadBranch |
| 78 | 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 | 76 | const branchNames = branches.map((branch) => branch.name); |
| 89 | 77 | const branchByName = new Map(branches.map((branch) => [branch.name, branch])); |
| 90 | 78 | const trimmedBranchQuery = branchQuery.trim(); |
| ... | ... | @@ -97,15 +85,20 @@ export default function BranchToolbar({ |
| 97 | 85 | createBranchItemValue && !hasExactBranchMatch |
| 98 | 86 | ? [...branchNames, createBranchItemValue] |
| 99 | 87 | : branchNames; |
| 100 | | // ── Mutations ───────────────────────────────────────────────────────── |
| 101 | 88 | |
| 102 | | const checkoutMutation = useMutation(gitCheckoutMutationOptions({ cwd: branchCwd, queryClient })); |
| 89 | // ── Helpers ─────────────────────────────────────────────────────────── |
| 103 | 90 | |
| 104 | | const createBranchMutation = useMutation( |
| 105 | | gitCreateBranchAndCheckoutMutationOptions({ cwd: branchCwd, queryClient }), |
| 91 | const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( |
| 92 | canonicalActiveBranch, |
| 93 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, |
| 106 | 94 | ); |
| 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 | }; |
| 109 | 102 | |
| 110 | 103 | const setThreadError = (error: string | null) => { |
| 111 | 104 | if (!activeThreadId) return; |
| ... | ... | @@ -150,11 +143,10 @@ export default function BranchToolbar({ |
| 150 | 143 | |
| 151 | 144 | const selectBranch = (branch: GitBranch) => { |
| 152 | 145 | const api = readNativeApi(); |
| 153 | | if (!api || !activeThreadId || !branchCwd) return; |
| 146 | if (!api || !activeThreadId || !branchCwd || isBranchActionPending) return; |
| 154 | 147 | |
| 155 | 148 | // For new worktree mode, selecting a branch picks the base branch. |
| 156 | 149 | if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) { |
| 157 | | setThreadError(null); |
| 158 | 150 | setThreadBranch(branch.name, null); |
| 159 | 151 | setIsBranchMenuOpen(false); |
| 160 | 152 | onComposerFocusRequest?.(); |
| ... | ... | @@ -165,7 +157,6 @@ export default function BranchToolbar({ |
| 165 | 157 | // trying to checkout (which git would reject with "already used by worktree"). |
| 166 | 158 | if (branch.worktreePath) { |
| 167 | 159 | const isMainWorktree = branch.worktreePath === activeProject?.cwd; |
| 168 | | setThreadError(null); |
| 169 | 160 | // Main worktree → switch back to local (project cwd, worktreePath=null). |
| 170 | 161 | // Secondary worktree → point the thread at that worktree path. |
| 171 | 162 | setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); |
| ... | ... | @@ -177,49 +168,69 @@ export default function BranchToolbar({ |
| 177 | 168 | const selectedBranchName = branch.isRemote |
| 178 | 169 | ? deriveLocalBranchNameFromRemoteRef(branch.name) |
| 179 | 170 | : branch.name; |
| 180 | | setOptimisticBranchInTransition(selectedBranchName); |
| 181 | 171 | |
| 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; |
| 191 | 193 | } |
| 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); |
| 202 | 198 | }); |
| 203 | 199 | }; |
| 204 | 200 | |
| 205 | 201 | const createBranch = (rawName: string) => { |
| 206 | 202 | const name = rawName.trim(); |
| 207 | 203 | 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(""); |
| 223 | 234 | }); |
| 224 | 235 | }; |
| 225 | 236 | |
| ... | ... | @@ -250,9 +261,8 @@ export default function BranchToolbar({ |
| 250 | 261 | autoHighlight |
| 251 | 262 | onOpenChange={(open) => { |
| 252 | 263 | setIsBranchMenuOpen(open); |
| 253 | | if (!open) { |
| 254 | | setBranchQuery(""); |
| 255 | | } |
| 264 | if (!open) setBranchQuery(""); |
| 265 | else void invalidateGitQueries(queryClient); |
| 256 | 266 | }} |
| 257 | 267 | open={isBranchMenuOpen} |
| 258 | 268 | value={resolvedActiveBranch} |
| ... | ... | @@ -260,7 +270,7 @@ export default function BranchToolbar({ |
| 260 | 270 | <ComboboxTrigger |
| 261 | 271 | render={<Button variant="ghost" size="xs" />} |
| 262 | 272 | className="text-muted-foreground/70 hover:text-foreground/80" |
| 263 | | disabled={branchesQuery.isLoading} |
| 273 | disabled={branchesQuery.isLoading || isBranchActionPending} |
| 264 | 274 | > |
| 265 | 275 | <span className="max-w-[240px] truncate"> |
| 266 | 276 | {resolvedActiveBranch |