| ... | @@ -1,13 +1,9 @@ | ... | @@ -1,13 +1,9 @@ |
| 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; | 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; |
| 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { startTransition, useOptimistic, useState } from "react"; | 3 | import { useOptimistic, useState, useTransition } from "react"; |
| 4 | | 4 | |
| 5 | import { newCommandId } from "../lib/utils"; | 5 | import { newCommandId } from "../lib/utils"; |
| 6 | import { | 6 | import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery"; |
| 7 | gitBranchesQueryOptions, | | |
| 8 | gitCheckoutMutationOptions, | | |
| 9 | gitCreateBranchAndCheckoutMutationOptions, | | |
| 10 | } from "../lib/gitReactQuery"; | | |
| 11 | import { readNativeApi } from "../nativeApi"; | 7 | import { readNativeApi } from "../nativeApi"; |
| 12 | import { useComposerDraftStore } from "../composerDraftStore"; | 8 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 13 | import { useStore } from "../store"; | 9 | import { 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"; |
| 28 | import { ChevronDownIcon } from "lucide-react"; | 24 | import { ChevronDownIcon } from "lucide-react"; |
| | 25 | import { toastManager } from "./ui/toast"; |
| 29 | | 26 | |
| 30 | interface BranchToolbarProps { | 27 | interface 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" && !activeWorktreePath | 73 | effectiveEnvMode === "worktree" && !activeWorktreePath |
| 77 | ? activeThreadBranch | 74 | ? 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 && !hasExactBranchMatch | 85 | createBranchItemValue && !hasExactBranchMatch |
| 98 | ? [...branchNames, createBranchItemValue] | 86 | ? [...branchNames, createBranchItemValue] |
| 99 | : branchNames; | 87 | : branchNames; |
| 100 | // ── Mutations ───────────────────────────────────────────────────────── | | |
| 101 | | 88 | |
| 102 | const checkoutMutation = useMutation(gitCheckoutMutationOptions({ cwd: branchCwd, queryClient })); | 89 | // ── Helpers ─────────────────────────────────────────────────────────── |
| 103 | | 90 | |
| 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 | ); |
| 107 | | 95 | 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 | }; |
| 109 | | 102 | |
| 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({ |
| 150 | | 143 | |
| 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; |
| 154 | | 147 | |
| 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.isRemote | 168 | const selectedBranchName = branch.isRemote |
| 178 | ? deriveLocalBranchNameFromRemoteRef(branch.name) | 169 | ? deriveLocalBranchNameFromRemoteRef(branch.name) |
| 179 | : branch.name; | 170 | : branch.name; |
| 180 | setOptimisticBranchInTransition(selectedBranchName); | | |
| 181 | | 171 | |
| 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 | }; |
| 204 | | 200 | |
| 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 | }; |
| 225 | | 236 | |
| ... | @@ -250,9 +261,8 @@ export default function BranchToolbar({ | ... | @@ -250,9 +261,8 @@ export default function BranchToolbar({ |
| 250 | autoHighlight | 261 | 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 | <ComboboxTrigger | 270 | <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 | {resolvedActiveBranch | 276 | {resolvedActiveBranch |