| author | |
| committer | |
| log | 85eb379dd769db2f949a3352acca6537b02e89cf |
| tree | ceb261480dcdfbca53b79335c54106dba7184b89 |
| parent | 5a30594009cc1170cd181649899c86b5452eff15 |
- add working-tree file/line stats to git status contracts, server responses, and tests
- refactor web git queries/mutations into shared React Query helpers
- update git quick-action logic/UI to prefer commit/push/PR flows and clearer disabled states
- hide branch toolbar for non-repos and tighten related chat/toolbar behavior12 files changed, 871 insertions(+), 353 deletions(-)
apps/server/src/git.ts+85-2| ... | ... | @@ -94,6 +94,51 @@ function parseBranchAb(value: string): { ahead: number; behind: number } { |
| 94 | 94 | }; |
| 95 | 95 | } |
| 96 | 96 | |
| 97 | function parseNumstatEntries( | |
| 98 | stdout: string, | |
| 99 | ): Array<{ path: string; insertions: number; deletions: number }> { | |
| 100 | const entries: Array<{ path: string; insertions: number; deletions: number }> = []; | |
| 101 | for (const line of stdout.split(/\r?\n/g)) { | |
| 102 | if (line.trim().length === 0) continue; | |
| 103 | const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); | |
| 104 | const rawPath = pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); | |
| 105 | if (rawPath.length === 0) continue; | |
| 106 | const added = Number.parseInt(addedRaw ?? "0", 10); | |
| 107 | const deleted = Number.parseInt(deletedRaw ?? "0", 10); | |
| 108 | const renameArrowIndex = rawPath.indexOf(" => "); | |
| 109 | const normalizedPath = | |
| 110 | renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; | |
| 111 | entries.push({ | |
| 112 | path: normalizedPath.length > 0 ? normalizedPath : rawPath, | |
| 113 | insertions: Number.isFinite(added) ? added : 0, | |
| 114 | deletions: Number.isFinite(deleted) ? deleted : 0, | |
| 115 | }); | |
| 116 | } | |
| 117 | return entries; | |
| 118 | } | |
| 119 | ||
| 120 | function parsePorcelainPath(line: string): string | null { | |
| 121 | if (line.startsWith("? ") || line.startsWith("! ")) { | |
| 122 | const simple = line.slice(2).trim(); | |
| 123 | return simple.length > 0 ? simple : null; | |
| 124 | } | |
| 125 | ||
| 126 | if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { | |
| 127 | return null; | |
| 128 | } | |
| 129 | ||
| 130 | const tabIndex = line.indexOf("\t"); | |
| 131 | if (tabIndex >= 0) { | |
| 132 | const fromTab = line.slice(tabIndex + 1); | |
| 133 | const [path] = fromTab.split("\t"); | |
| 134 | return path?.trim().length ? path.trim() : null; | |
| 135 | } | |
| 136 | ||
| 137 | const parts = line.trim().split(/\s+/g); | |
| 138 | const path = parts.at(-1) ?? ""; | |
| 139 | return path.length > 0 ? path : null; | |
| 140 | } | |
| 141 | ||
| 97 | 142 | function commandLabel(args: readonly string[]): string { |
| 98 | 143 | return `git ${args.join(" ")}`; |
| 99 | 144 | } |
| ... | ... | @@ -176,6 +221,7 @@ export class GitCoreService { |
| 176 | 221 | return gitStatusResultSchema.parse({ |
| 177 | 222 | branch: details.branch, |
| 178 | 223 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 224 | workingTree: details.workingTree, | |
| 179 | 225 | hasUpstream: details.hasUpstream, |
| 180 | 226 | aheadCount: details.aheadCount, |
| 181 | 227 | behindCount: details.behindCount, |
| ... | ... | @@ -184,15 +230,20 @@ export class GitCoreService { |
| 184 | 230 | } |
| 185 | 231 | |
| 186 | 232 | async statusDetails(cwd: string): Promise<GitStatusDetails> { |
| 187 | const stdout = await this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]); | |
| 233 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ | |
| 234 | this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), | |
| 235 | this.gitStdout(cwd, ["diff", "--numstat"]), | |
| 236 | this.gitStdout(cwd, ["diff", "--cached", "--numstat"]), | |
| 237 | ]); | |
| 188 | 238 | |
| 189 | 239 | let branch: string | null = null; |
| 190 | 240 | let upstreamRef: string | null = null; |
| 191 | 241 | let aheadCount = 0; |
| 192 | 242 | let behindCount = 0; |
| 193 | 243 | let hasWorkingTreeChanges = false; |
| 244 | const changedFilesWithoutNumstat = new Set<string>(); | |
| 194 | 245 | |
| 195 | for (const line of stdout.split(/\r?\n/g)) { | |
| 246 | for (const line of statusStdout.split(/\r?\n/g)) { | |
| 196 | 247 | if (line.startsWith("# branch.head ")) { |
| 197 | 248 | const value = line.slice("# branch.head ".length).trim(); |
| 198 | 249 | branch = value.startsWith("(") ? null : value; |
| ... | ... | @@ -212,13 +263,45 @@ export class GitCoreService { |
| 212 | 263 | } |
| 213 | 264 | if (line.trim().length > 0 && !line.startsWith("#")) { |
| 214 | 265 | hasWorkingTreeChanges = true; |
| 266 | const pathValue = parsePorcelainPath(line); | |
| 267 | if (pathValue) changedFilesWithoutNumstat.add(pathValue); | |
| 215 | 268 | } |
| 216 | 269 | } |
| 270 | const stagedEntries = parseNumstatEntries(stagedNumstatStdout); | |
| 271 | const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); | |
| 272 | const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); | |
| 273 | for (const entry of [...stagedEntries, ...unstagedEntries]) { | |
| 274 | const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; | |
| 275 | existing.insertions += entry.insertions; | |
| 276 | existing.deletions += entry.deletions; | |
| 277 | fileStatMap.set(entry.path, existing); | |
| 278 | } | |
| 279 | ||
| 280 | let insertions = 0; | |
| 281 | let deletions = 0; | |
| 282 | const files = Array.from(fileStatMap.entries()) | |
| 283 | .map(([path, stat]) => { | |
| 284 | insertions += stat.insertions; | |
| 285 | deletions += stat.deletions; | |
| 286 | return { path, insertions: stat.insertions, deletions: stat.deletions }; | |
| 287 | }) | |
| 288 | .toSorted((a, b) => a.path.localeCompare(b.path)); | |
| 289 | ||
| 290 | for (const filePath of changedFilesWithoutNumstat) { | |
| 291 | if (fileStatMap.has(filePath)) continue; | |
| 292 | files.push({ path: filePath, insertions: 0, deletions: 0 }); | |
| 293 | } | |
| 294 | files.sort((a, b) => a.path.localeCompare(b.path)); | |
| 217 | 295 | |
| 218 | 296 | return { |
| 219 | 297 | branch, |
| 220 | 298 | upstreamRef, |
| 221 | 299 | hasWorkingTreeChanges, |
| 300 | workingTree: { | |
| 301 | files, | |
| 302 | insertions, | |
| 303 | deletions, | |
| 304 | }, | |
| 222 | 305 | hasUpstream: upstreamRef !== null, |
| 223 | 306 | aheadCount, |
| 224 | 307 | behindCount, |
apps/server/src/gitManager.ts+1| ... | ... | @@ -218,6 +218,7 @@ export class GitManager { |
| 218 | 218 | return { |
| 219 | 219 | branch: details.branch, |
| 220 | 220 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 221 | workingTree: details.workingTree, | |
| 221 | 222 | hasUpstream: details.hasUpstream, |
| 222 | 223 | aheadCount: details.aheadCount, |
| 223 | 224 | behindCount: details.behindCount, |
apps/server/src/wsServer.test.ts+10| ... | ... | @@ -557,6 +557,11 @@ describe("WebSocket Server", () => { |
| 557 | 557 | status: vi.fn().mockResolvedValue({ |
| 558 | 558 | branch: "feature/test", |
| 559 | 559 | hasWorkingTreeChanges: true, |
| 560 | workingTree: { | |
| 561 | files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], | |
| 562 | insertions: 7, | |
| 563 | deletions: 2, | |
| 564 | }, | |
| 560 | 565 | hasUpstream: false, |
| 561 | 566 | aheadCount: 0, |
| 562 | 567 | behindCount: 0, |
| ... | ... | @@ -581,6 +586,11 @@ describe("WebSocket Server", () => { |
| 581 | 586 | expect(response.result).toEqual({ |
| 582 | 587 | branch: "feature/test", |
| 583 | 588 | hasWorkingTreeChanges: true, |
| 589 | workingTree: { | |
| 590 | files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], | |
| 591 | insertions: 7, | |
| 592 | deletions: 2, | |
| 593 | }, | |
| 584 | 594 | hasUpstream: false, |
| 585 | 595 | aheadCount: 0, |
| 586 | 596 | behindCount: 0, |
apps/web/src/components/BranchToolbar.tsx+118-152| ... | ... | @@ -3,6 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | |
| 5 | 5 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 6 | import { | |
| 7 | gitBranchesQueryOptions, | |
| 8 | gitCheckoutMutationOptions, | |
| 9 | gitCreateBranchAndCheckoutMutationOptions, | |
| 10 | } from "../lib/gitReactQuery"; | |
| 6 | 11 | import { useStore } from "../store"; |
| 7 | 12 | import { Button } from "./ui/button"; |
| 8 | 13 | import { |
| ... | ... | @@ -37,28 +42,19 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 37 | 42 | const activeThreadId = activeThread?.id; |
| 38 | 43 | const activeThreadBranch = activeThread?.branch ?? null; |
| 39 | 44 | const activeWorktreePath = activeThread?.worktreePath ?? null; |
| 40 | const branchCwd = activeWorktreePath ?? activeProject?.cwd; | |
| 45 | const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null; | |
| 41 | 46 | |
| 42 | 47 | // ── Queries ─────────────────────────────────────────────────────────── |
| 43 | 48 | |
| 44 | const branchesQuery = useQuery({ | |
| 45 | queryKey: ["git", "branches", branchCwd], | |
| 46 | queryFn: () => api!.git.listBranches({ cwd: branchCwd! }), | |
| 47 | enabled: !!api && !!branchCwd, | |
| 48 | }); | |
| 49 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, branchCwd)); | |
| 49 | 50 | |
| 50 | 51 | const branches = branchesQuery.data?.branches ?? []; |
| 51 | 52 | const branchNames = branches.map((branch) => branch.name); |
| 52 | 53 | const branchByName = new Map(branches.map((branch) => [branch.name, branch])); |
| 53 | // Default to true while loading — showing "Initialize git" during a fetch is wrong, | |
| 54 | // and worktrees are inherently git repos. | |
| 55 | const isRepo = branchesQuery.data?.isRepo ?? !branchesQuery.isLoading; | |
| 56 | ||
| 57 | 54 | // ── Mutations ───────────────────────────────────────────────────────── |
| 58 | 55 | |
| 59 | 56 | const checkoutMutation = useMutation({ |
| 60 | mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }), | |
| 61 | onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }), | |
| 57 | ...gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | |
| 62 | 58 | onError: (error) => { |
| 63 | 59 | setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."); |
| 64 | 60 | setIsBranchMenuOpen(true); |
| ... | ... | @@ -66,25 +62,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 66 | 62 | }); |
| 67 | 63 | |
| 68 | 64 | const createBranchMutation = useMutation({ |
| 69 | mutationFn: (branch: string) => | |
| 70 | api!.git | |
| 71 | .createBranch({ cwd: branchCwd!, branch }) | |
| 72 | .then(() => api!.git.checkout({ cwd: branchCwd!, branch })), | |
| 73 | onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }), | |
| 65 | ...gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | |
| 74 | 66 | onError: (error) => |
| 75 | 67 | setThreadError(error instanceof Error ? error.message : "Failed to create branch."), |
| 76 | 68 | }); |
| 77 | 69 | |
| 78 | const initMutation = useMutation({ | |
| 79 | mutationFn: () => api!.git.init({ cwd: branchCwd! }), | |
| 80 | onSuccess: () => { | |
| 81 | setThreadError(null); | |
| 82 | queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }); | |
| 83 | }, | |
| 84 | onError: (error) => | |
| 85 | setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo."), | |
| 86 | }); | |
| 87 | ||
| 88 | 70 | // ── Effects ─────────────────────────────────────────────────────────── |
| 89 | 71 | |
| 90 | 72 | // Keep thread branch synced to git current branch for local threads. |
| ... | ... | @@ -194,134 +176,118 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 194 | 176 | )} |
| 195 | 177 | </div> |
| 196 | 178 | |
| 197 | {!isRepo ? ( | |
| 198 | <button | |
| 199 | type="button" | |
| 200 | className="inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[12px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-50" | |
| 201 | disabled={!branchCwd || initMutation.isPending} | |
| 202 | onClick={() => initMutation.mutate()} | |
| 203 | > | |
| 204 | {initMutation.isPending ? "Initializing\u2026" : "Initialize git"} | |
| 205 | </button> | |
| 206 | ) : ( | |
| 207 | <Combobox | |
| 208 | items={branchNames} | |
| 209 | autoHighlight | |
| 210 | onOpenChange={(open) => setIsBranchMenuOpen(open)} | |
| 211 | open={isBranchMenuOpen} | |
| 212 | value={activeThread.branch} | |
| 179 | <Combobox | |
| 180 | items={branchNames} | |
| 181 | autoHighlight | |
| 182 | onOpenChange={(open) => setIsBranchMenuOpen(open)} | |
| 183 | open={isBranchMenuOpen} | |
| 184 | value={activeThread.branch} | |
| 185 | > | |
| 186 | <ComboboxTrigger | |
| 187 | render={<Button variant="ghost" size="xs" />} | |
| 188 | className="text-muted-foreground/70 hover:text-foreground/80" | |
| 189 | disabled={branchesQuery.isLoading} | |
| 213 | 190 | > |
| 214 | <ComboboxTrigger | |
| 215 | render={<Button variant="ghost" size="xs" />} | |
| 216 | className="text-muted-foreground/70 hover:text-foreground/80" | |
| 217 | disabled={branchesQuery.isLoading} | |
| 218 | > | |
| 219 | <span className="max-w-[240px] truncate"> | |
| 220 | {activeThread.branch | |
| 221 | ? envMode === "worktree" && !activeWorktreePath | |
| 222 | ? `From ${activeThread.branch}` | |
| 223 | : activeThread.branch | |
| 224 | : "Select branch"} | |
| 225 | </span> | |
| 226 | <ChevronDownIcon /> | |
| 227 | </ComboboxTrigger> | |
| 228 | <ComboboxPopup align="end" side="top" className="w-64"> | |
| 229 | <div className="border-b"> | |
| 230 | <ComboboxInput | |
| 231 | className="rounded-b-none before:rounded-b-none [&_input]:font-sans" | |
| 232 | placeholder="Search branches..." | |
| 233 | showClear | |
| 234 | showTrigger={false} | |
| 235 | size="sm" | |
| 236 | /> | |
| 237 | </div> | |
| 238 | <ComboboxEmpty>No branches found.</ComboboxEmpty> | |
| 191 | <span className="max-w-[240px] truncate"> | |
| 192 | {activeThread.branch | |
| 193 | ? envMode === "worktree" && !activeWorktreePath | |
| 194 | ? `From ${activeThread.branch}` | |
| 195 | : activeThread.branch | |
| 196 | : "Select branch"} | |
| 197 | </span> | |
| 198 | <ChevronDownIcon /> | |
| 199 | </ComboboxTrigger> | |
| 200 | <ComboboxPopup align="end" side="top" className="w-64"> | |
| 201 | <div className="border-b"> | |
| 202 | <ComboboxInput | |
| 203 | className="rounded-b-none before:rounded-b-none [&_input]:font-sans" | |
| 204 | placeholder="Search branches..." | |
| 205 | showClear | |
| 206 | showTrigger={false} | |
| 207 | size="sm" | |
| 208 | /> | |
| 209 | </div> | |
| 210 | <ComboboxEmpty>No branches found.</ComboboxEmpty> | |
| 239 | 211 | |
| 240 | <ComboboxList className="max-h-56"> | |
| 241 | {(branchName) => { | |
| 242 | const branch = branchByName.get(branchName); | |
| 243 | if (!branch) return null; | |
| 212 | <ComboboxList className="max-h-56"> | |
| 213 | {(branchName) => { | |
| 214 | const branch = branchByName.get(branchName); | |
| 215 | if (!branch) return null; | |
| 244 | 216 | |
| 245 | const hasSecondaryWorktree = | |
| 246 | branch.worktreePath && branch.worktreePath !== activeProject.cwd; | |
| 247 | return ( | |
| 248 | <ComboboxItem | |
| 249 | hideIndicator | |
| 250 | key={branchName} | |
| 251 | value={branchName} | |
| 252 | className={branchName === activeThread.branch ? "bg-accent text-foreground" : undefined} | |
| 253 | onClick={() => selectBranch(branch)} | |
| 254 | > | |
| 255 | <div className="flex w-full items-center justify-between gap-2"> | |
| 256 | <span className="truncate">{branchName}</span> | |
| 257 | {(branch.current || branch.isDefault || hasSecondaryWorktree) && ( | |
| 258 | <span className="shrink-0 text-[10px] text-muted-foreground/45"> | |
| 259 | {branch.current | |
| 260 | ? "current" | |
| 261 | : hasSecondaryWorktree | |
| 262 | ? "worktree" | |
| 263 | : "default"} | |
| 264 | </span> | |
| 265 | )} | |
| 266 | </div> | |
| 267 | </ComboboxItem> | |
| 268 | ); | |
| 269 | }} | |
| 270 | </ComboboxList> | |
| 271 | {envMode === "local" && ( | |
| 272 | <> | |
| 273 | {isCreatingBranch ? ( | |
| 274 | <form | |
| 275 | onSubmit={(event) => { | |
| 276 | event.preventDefault(); | |
| 277 | createBranch(); | |
| 278 | }} | |
| 279 | > | |
| 280 | <InputGroup className="rounded-t-none before:rounded-t-none"> | |
| 281 | <InputGroupInput | |
| 282 | className="[&_input]:font-sans" | |
| 283 | size="sm" | |
| 284 | placeholder="branch-name" | |
| 285 | type="text" | |
| 286 | value={newBranchName} | |
| 287 | onChange={(event) => setNewBranchName(event.target.value)} | |
| 288 | onKeyDown={(event) => { | |
| 289 | if (event.key === "Escape") { | |
| 290 | event.stopPropagation(); | |
| 291 | setIsCreatingBranch(false); | |
| 292 | setNewBranchName(""); | |
| 293 | } | |
| 294 | }} | |
| 295 | autoFocus | |
| 296 | /> | |
| 297 | <InputGroupAddon align="inline-end"> | |
| 298 | <Button | |
| 299 | size="xs" | |
| 300 | variant="secondary" | |
| 301 | type="submit" | |
| 302 | disabled={!newBranchName.trim() || createBranchMutation.isPending} | |
| 303 | > | |
| 304 | Create | |
| 305 | </Button> | |
| 306 | </InputGroupAddon> | |
| 307 | </InputGroup> | |
| 308 | </form> | |
| 309 | ) : ( | |
| 310 | <Button | |
| 311 | size="sm" | |
| 312 | className="w-full justify-start h-9 rounded-t-none rounded-b-lg before:rounded-t-none" | |
| 313 | variant="outline" | |
| 314 | onClick={() => setIsCreatingBranch(true)} | |
| 315 | > | |
| 316 | <PlusIcon /> | |
| 317 | New branch | |
| 318 | </Button> | |
| 319 | )} | |
| 320 | </> | |
| 321 | )} | |
| 322 | </ComboboxPopup> | |
| 323 | </Combobox> | |
| 324 | )} | |
| 217 | const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProject.cwd; | |
| 218 | return ( | |
| 219 | <ComboboxItem | |
| 220 | hideIndicator | |
| 221 | key={branchName} | |
| 222 | value={branchName} | |
| 223 | className={branchName === activeThread.branch ? "bg-accent text-foreground" : undefined} | |
| 224 | onClick={() => selectBranch(branch)} | |
| 225 | > | |
| 226 | <div className="flex w-full items-center justify-between gap-2"> | |
| 227 | <span className="truncate">{branchName}</span> | |
| 228 | {(branch.current || branch.isDefault || hasSecondaryWorktree) && ( | |
| 229 | <span className="shrink-0 text-[10px] text-muted-foreground/45"> | |
| 230 | {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"} | |
| 231 | </span> | |
| 232 | )} | |
| 233 | </div> | |
| 234 | </ComboboxItem> | |
| 235 | ); | |
| 236 | }} | |
| 237 | </ComboboxList> | |
| 238 | {envMode === "local" && ( | |
| 239 | <> | |
| 240 | {isCreatingBranch ? ( | |
| 241 | <form | |
| 242 | onSubmit={(event) => { | |
| 243 | event.preventDefault(); | |
| 244 | createBranch(); | |
| 245 | }} | |
| 246 | > | |
| 247 | <InputGroup className="rounded-t-none before:rounded-t-none"> | |
| 248 | <InputGroupInput | |
| 249 | className="[&_input]:font-sans" | |
| 250 | size="sm" | |
| 251 | placeholder="branch-name" | |
| 252 | type="text" | |
| 253 | value={newBranchName} | |
| 254 | onChange={(event) => setNewBranchName(event.target.value)} | |
| 255 | onKeyDown={(event) => { | |
| 256 | if (event.key === "Escape") { | |
| 257 | event.stopPropagation(); | |
| 258 | setIsCreatingBranch(false); | |
| 259 | setNewBranchName(""); | |
| 260 | } | |
| 261 | }} | |
| 262 | autoFocus | |
| 263 | /> | |
| 264 | <InputGroupAddon align="inline-end"> | |
| 265 | <Button | |
| 266 | size="xs" | |
| 267 | variant="secondary" | |
| 268 | type="submit" | |
| 269 | disabled={!newBranchName.trim() || createBranchMutation.isPending} | |
| 270 | > | |
| 271 | Create | |
| 272 | </Button> | |
| 273 | </InputGroupAddon> | |
| 274 | </InputGroup> | |
| 275 | </form> | |
| 276 | ) : ( | |
| 277 | <Button | |
| 278 | size="sm" | |
| 279 | className="w-full justify-start h-9 rounded-t-none rounded-b-lg before:rounded-t-none" | |
| 280 | variant="outline" | |
| 281 | onClick={() => setIsCreatingBranch(true)} | |
| 282 | > | |
| 283 | <PlusIcon /> | |
| 284 | New branch | |
| 285 | </Button> | |
| 286 | )} | |
| 287 | </> | |
| 288 | )} | |
| 289 | </ComboboxPopup> | |
| 290 | </Combobox> | |
| 325 | 291 | </div> |
| 326 | 292 | ); |
| 327 | 293 | } |
apps/web/src/components/ChatView.tsx+8-3| ... | ... | @@ -19,6 +19,8 @@ import { |
| 19 | 19 | useRef, |
| 20 | 20 | useState, |
| 21 | 21 | } from "react"; |
| 22 | import { useQuery } from "@tanstack/react-query"; | |
| 23 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; | |
| 22 | 24 | |
| 23 | 25 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 24 | 26 | import { isElectron } from "../env"; |
| ... | ... | @@ -244,6 +246,9 @@ export default function ChatView() { |
| 244 | 246 | sandboxMode: "workspace-write", |
| 245 | 247 | } as const); |
| 246 | 248 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 249 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); | |
| 250 | // Default true while loading to avoid toolbar flicker. | |
| 251 | const isGitRepo = branchesQuery.data?.isRepo ?? true; | |
| 247 | 252 | |
| 248 | 253 | const envLocked = Boolean( |
| 249 | 254 | activeThread && |
| ... | ... | @@ -1123,7 +1128,7 @@ export default function ChatView() { |
| 1123 | 1128 | </div> |
| 1124 | 1129 | |
| 1125 | 1130 | {/* Input bar */} |
| 1126 | <div className="px-5 pb-1 pt-2"> | |
| 1131 | <div className={cn("px-5 pt-2", isGitRepo ? "pb-1" : "pb-4")}> | |
| 1127 | 1132 | <form onSubmit={onSend} className="mx-auto max-w-3xl"> |
| 1128 | 1133 | <div |
| 1129 | 1134 | className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${ |
| ... | ... | @@ -1299,7 +1304,7 @@ export default function ChatView() { |
| 1299 | 1304 | </form> |
| 1300 | 1305 | </div> |
| 1301 | 1306 | |
| 1302 | <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} /> | |
| 1307 | {isGitRepo && <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />} | |
| 1303 | 1308 | |
| 1304 | 1309 | {activeThread.terminalOpen && api && activeProject && ( |
| 1305 | 1310 | <ThreadTerminalDrawer |
| ... | ... | @@ -1436,8 +1441,8 @@ function OpenInPicker() { |
| 1436 | 1441 | |
| 1437 | 1442 | const api = useNativeApi(); |
| 1438 | 1443 | const { state } = useStore(); |
| 1439 | const activeProject = state.projects.find((p) => p.id === state.activeThreadId); | |
| 1440 | 1444 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 1445 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | |
| 1441 | 1446 | |
| 1442 | 1447 | const openInEditor = useCallback( |
| 1443 | 1448 | (editorId: EditorId | null) => { |
apps/web/src/components/GitActionsControl.logic.test.ts+73-18| ... | ... | @@ -10,6 +10,11 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { |
| 10 | 10 | return { |
| 11 | 11 | branch: "feature/test", |
| 12 | 12 | hasWorkingTreeChanges: false, |
| 13 | workingTree: { | |
| 14 | files: [], | |
| 15 | insertions: 0, | |
| 16 | deletions: 0, | |
| 17 | }, | |
| 13 | 18 | hasUpstream: true, |
| 14 | 19 | aheadCount: 0, |
| 15 | 20 | behindCount: 0, |
| ... | ... | @@ -81,7 +86,7 @@ describe("when: actions are busy", () => { |
| 81 | 86 | const quick = resolveQuickAction(status(), true); |
| 82 | 87 | assert.deepInclude(quick, { |
| 83 | 88 | kind: "show_hint", |
| 84 | label: "Running...", | |
| 89 | label: "Commit", | |
| 85 | 90 | disabled: true, |
| 86 | 91 | hint: "Git action in progress.", |
| 87 | 92 | }); |
| ... | ... | @@ -123,7 +128,7 @@ describe("when: git status is unavailable", () => { |
| 123 | 128 | const quick = resolveQuickAction(null, false); |
| 124 | 129 | assert.deepInclude(quick, { |
| 125 | 130 | kind: "show_hint", |
| 126 | label: "Git actions", | |
| 131 | label: "Commit", | |
| 127 | 132 | disabled: true, |
| 128 | 133 | hint: "Git status is unavailable.", |
| 129 | 134 | }); |
| ... | ... | @@ -314,9 +319,34 @@ describe("when: branch is behind upstream", () => { |
| 314 | 319 | }); |
| 315 | 320 | |
| 316 | 321 | describe("when: working tree has local changes", () => { |
| 317 | it("resolveQuickAction returns commit", () => { | |
| 322 | it("resolveQuickAction returns commit, push, and create PR", () => { | |
| 318 | 323 | const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false); |
| 319 | assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" }); | |
| 324 | assert.deepInclude(quick, { | |
| 325 | kind: "run_action", | |
| 326 | action: "commit_push_pr", | |
| 327 | label: "Commit, push & create PR", | |
| 328 | }); | |
| 329 | }); | |
| 330 | ||
| 331 | it("resolveQuickAction returns commit and push when open PR exists", () => { | |
| 332 | const quick = resolveQuickAction( | |
| 333 | status({ | |
| 334 | hasWorkingTreeChanges: true, | |
| 335 | openPr: { | |
| 336 | number: 16, | |
| 337 | title: "Existing PR", | |
| 338 | url: "https://example.com/pr/16", | |
| 339 | baseBranch: "main", | |
| 340 | headBranch: "feature/test", | |
| 341 | }, | |
| 342 | }), | |
| 343 | false, | |
| 344 | ); | |
| 345 | assert.deepInclude(quick, { | |
| 346 | kind: "run_action", | |
| 347 | action: "commit_push", | |
| 348 | label: "Commit & push", | |
| 349 | }); | |
| 320 | 350 | }); |
| 321 | 351 | |
| 322 | 352 | it("buildMenuItems enables commit and disables push and PR", () => { |
| ... | ... | @@ -350,10 +380,36 @@ describe("when: working tree has local changes", () => { |
| 350 | 380 | }); |
| 351 | 381 | }); |
| 352 | 382 | |
| 383 | describe("when: on default branch without open PR", () => { | |
| 384 | it("resolveQuickAction returns commit and push when local changes exist", () => { | |
| 385 | const quick = resolveQuickAction(status({ branch: "main", hasWorkingTreeChanges: true }), false, true); | |
| 386 | assert.deepInclude(quick, { | |
| 387 | kind: "run_action", | |
| 388 | action: "commit_push", | |
| 389 | label: "Commit & push", | |
| 390 | disabled: false, | |
| 391 | }); | |
| 392 | }); | |
| 393 | ||
| 394 | it("resolveQuickAction returns push when branch is ahead", () => { | |
| 395 | const quick = resolveQuickAction(status({ branch: "main", aheadCount: 2, openPr: null }), false, true); | |
| 396 | assert.deepInclude(quick, { | |
| 397 | kind: "run_action", | |
| 398 | action: "commit_push", | |
| 399 | label: "Push", | |
| 400 | disabled: false, | |
| 401 | }); | |
| 402 | }); | |
| 403 | }); | |
| 404 | ||
| 353 | 405 | describe("when: working tree has local changes and branch is behind upstream", () => { |
| 354 | it("resolveQuickAction still prefers commit", () => { | |
| 406 | it("resolveQuickAction still prefers commit, push, and create PR", () => { | |
| 355 | 407 | const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true, behindCount: 1 }), false); |
| 356 | assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" }); | |
| 408 | assert.deepInclude(quick, { | |
| 409 | kind: "run_action", | |
| 410 | action: "commit_push_pr", | |
| 411 | label: "Commit, push & create PR", | |
| 412 | }); | |
| 357 | 413 | }); |
| 358 | 414 | |
| 359 | 415 | it("buildMenuItems enables commit and keeps push and PR disabled", () => { |
| ... | ... | @@ -393,7 +449,7 @@ describe("when: HEAD is detached and there are no local changes", () => { |
| 393 | 449 | status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }), |
| 394 | 450 | false, |
| 395 | 451 | ); |
| 396 | assert.deepInclude(quick, { kind: "show_hint", label: "Detached HEAD", disabled: false }); | |
| 452 | assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); | |
| 397 | 453 | }); |
| 398 | 454 | |
| 399 | 455 | it("buildMenuItems keeps commit, push, and PR disabled", () => { |
| ... | ... | @@ -428,17 +484,16 @@ describe("when: HEAD is detached and there are no local changes", () => { |
| 428 | 484 | }); |
| 429 | 485 | |
| 430 | 486 | describe("when: branch has no upstream configured", () => { |
| 431 | it("resolveQuickAction runs push and create PR when clean and no open PR", () => { | |
| 487 | it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => { | |
| 432 | 488 | const quick = resolveQuickAction(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false); |
| 433 | 489 | assert.deepInclude(quick, { |
| 434 | kind: "run_action", | |
| 435 | action: "commit_push_pr", | |
| 436 | label: "Push & create PR", | |
| 437 | disabled: false, | |
| 490 | kind: "show_hint", | |
| 491 | label: "Commit", | |
| 492 | disabled: true, | |
| 438 | 493 | }); |
| 439 | 494 | }); |
| 440 | 495 | |
| 441 | it("resolveQuickAction opens PR when clean, no upstream, and no local commits ahead", () => { | |
| 496 | it("resolveQuickAction remains disabled when clean, no upstream, and no local commits ahead", () => { | |
| 442 | 497 | const quick = resolveQuickAction( |
| 443 | 498 | status({ |
| 444 | 499 | hasUpstream: false, |
| ... | ... | @@ -454,9 +509,9 @@ describe("when: branch has no upstream configured", () => { |
| 454 | 509 | false, |
| 455 | 510 | ); |
| 456 | 511 | assert.deepInclude(quick, { |
| 457 | kind: "open_pr", | |
| 458 | label: "Open PR", | |
| 459 | disabled: false, | |
| 512 | kind: "show_hint", | |
| 513 | label: "Commit", | |
| 514 | disabled: true, | |
| 460 | 515 | }); |
| 461 | 516 | }); |
| 462 | 517 | |
| ... | ... | @@ -483,7 +538,7 @@ describe("when: branch has no upstream configured", () => { |
| 483 | 538 | }); |
| 484 | 539 | }); |
| 485 | 540 | |
| 486 | it("buildMenuItems enables push and disables create PR", () => { | |
| 541 | it("buildMenuItems disables push and create PR when no commits are ahead", () => { | |
| 487 | 542 | const items = buildMenuItems(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false); |
| 488 | 543 | assert.deepEqual(items, [ |
| 489 | 544 | { |
| ... | ... | @@ -497,7 +552,7 @@ describe("when: branch has no upstream configured", () => { |
| 497 | 552 | { |
| 498 | 553 | id: "push", |
| 499 | 554 | label: "Push", |
| 500 | disabled: false, | |
| 555 | disabled: true, | |
| 501 | 556 | icon: "push", |
| 502 | 557 | kind: "open_dialog", |
| 503 | 558 | dialogAction: "push", |
apps/web/src/components/GitActionsControl.logic.ts+34-36| ... | ... | @@ -61,7 +61,7 @@ export function buildMenuItems( |
| 61 | 61 | hasBranch && |
| 62 | 62 | !hasChanges && |
| 63 | 63 | !isBehind && |
| 64 | (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream); | |
| 64 | gitStatus.aheadCount > 0; | |
| 65 | 65 | const canCreatePr = |
| 66 | 66 | !isBusy && |
| 67 | 67 | hasBranch && |
| ... | ... | @@ -111,14 +111,15 @@ export function buildMenuItems( |
| 111 | 111 | export function resolveQuickAction( |
| 112 | 112 | gitStatus: GitStatusResult | null, |
| 113 | 113 | isBusy: boolean, |
| 114 | isDefaultBranch = false, | |
| 114 | 115 | ): GitQuickAction { |
| 115 | 116 | if (isBusy) { |
| 116 | return { label: "Running...", disabled: true, kind: "show_hint", hint: "Git action in progress." }; | |
| 117 | return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; | |
| 117 | 118 | } |
| 118 | 119 | |
| 119 | 120 | if (!gitStatus) { |
| 120 | 121 | return { |
| 121 | label: "Git actions", | |
| 122 | label: "Commit", | |
| 122 | 123 | disabled: true, |
| 123 | 124 | kind: "show_hint", |
| 124 | 125 | hint: "Git status is unavailable.", |
| ... | ... | @@ -128,14 +129,31 @@ export function resolveQuickAction( |
| 128 | 129 | const hasBranch = gitStatus.branch !== null; |
| 129 | 130 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 130 | 131 | const hasOpenPr = gitStatus.openPr !== null; |
| 132 | const isAhead = gitStatus.aheadCount > 0; | |
| 131 | 133 | const isBehind = gitStatus.behindCount > 0; |
| 132 | const canPush = hasBranch && !hasChanges && !isBehind; | |
| 133 | 134 | |
| 134 | if (!hasChanges && hasOpenPr && gitStatus.aheadCount === 0 && !isBehind) { | |
| 135 | return { label: "Open PR", disabled: false, kind: "open_pr" }; | |
| 135 | if (!hasBranch) { | |
| 136 | return { | |
| 137 | label: "Commit", | |
| 138 | disabled: true, | |
| 139 | kind: "show_hint", | |
| 140 | hint: "Create and checkout a branch before pushing or opening a PR.", | |
| 141 | }; | |
| 136 | 142 | } |
| 137 | 143 | |
| 138 | if (!hasChanges && isBehind) { | |
| 144 | if (hasChanges) { | |
| 145 | if (hasOpenPr || isDefaultBranch) { | |
| 146 | return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; | |
| 147 | } | |
| 148 | return { | |
| 149 | label: "Commit, push & create PR", | |
| 150 | disabled: false, | |
| 151 | kind: "run_action", | |
| 152 | action: "commit_push_pr", | |
| 153 | }; | |
| 154 | } | |
| 155 | ||
| 156 | if (isBehind) { | |
| 139 | 157 | return { |
| 140 | 158 | label: "Pull", |
| 141 | 159 | disabled: false, |
| ... | ... | @@ -143,8 +161,8 @@ export function resolveQuickAction( |
| 143 | 161 | }; |
| 144 | 162 | } |
| 145 | 163 | |
| 146 | if (!hasChanges && canPush && (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream)) { | |
| 147 | if (hasOpenPr) { | |
| 164 | if (isAhead) { | |
| 165 | if (hasOpenPr || isDefaultBranch) { | |
| 148 | 166 | return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; |
| 149 | 167 | } |
| 150 | 168 | return { |
| ... | ... | @@ -155,36 +173,16 @@ export function resolveQuickAction( |
| 155 | 173 | }; |
| 156 | 174 | } |
| 157 | 175 | |
| 158 | if (hasChanges) { | |
| 159 | return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; | |
| 160 | } | |
| 161 | ||
| 162 | if (hasOpenPr) { | |
| 176 | if (hasOpenPr && gitStatus.hasUpstream) { | |
| 163 | 177 | return { label: "Open PR", disabled: false, kind: "open_pr" }; |
| 164 | 178 | } |
| 165 | 179 | |
| 166 | if (hasBranch && gitStatus.hasUpstream && !isBehind) { | |
| 167 | if (gitStatus.aheadCount > 0) { | |
| 168 | return { label: "Create PR", disabled: false, kind: "run_action", action: "commit_push_pr" }; | |
| 169 | } | |
| 170 | return { | |
| 171 | label: "Commit", | |
| 172 | disabled: true, | |
| 173 | kind: "show_hint", | |
| 174 | hint: "Branch is up to date. Nothing to commit, push, pull, or open as a PR.", | |
| 175 | }; | |
| 176 | } | |
| 177 | ||
| 178 | if (!hasBranch) { | |
| 179 | return { | |
| 180 | label: "Detached HEAD", | |
| 181 | disabled: false, | |
| 182 | kind: "show_hint", | |
| 183 | hint: "Create and checkout a branch before pushing or opening a PR.", | |
| 184 | }; | |
| 185 | } | |
| 186 | ||
| 187 | return { label: "Commit", disabled: true, kind: "show_hint", hint: "No git action needed." }; | |
| 180 | return { | |
| 181 | label: "Commit", | |
| 182 | disabled: true, | |
| 183 | kind: "show_hint", | |
| 184 | hint: "Branch is up to date. Nothing to commit, push, pull, or open as a PR.", | |
| 185 | }; | |
| 188 | 186 | } |
| 189 | 187 | |
| 190 | 188 | export function requiresDefaultBranchConfirmation( |
apps/web/src/components/GitActionsControl.tsx+386-138| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | 1 | import { |
| 2 | type GitStatusResult, | |
| 2 | 3 | type GitStackedAction, |
| 3 | 4 | type NativeApi, |
| 4 | 5 | } from "@t3tools/contracts"; |
| 5 | import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 6 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 6 | 7 | import { useCallback, useMemo, useState } from "react"; |
| 7 | 8 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| 8 | 9 | import { GitHubIcon } from "./Icons"; |
| ... | ... | @@ -16,6 +17,7 @@ import { |
| 16 | 17 | requiresDefaultBranchConfirmation, |
| 17 | 18 | resolveQuickAction, |
| 18 | 19 | } from "./GitActionsControl.logic"; |
| 20 | import { Badge } from "~/components/ui/badge"; | |
| 19 | 21 | import { Button } from "~/components/ui/button"; |
| 20 | 22 | import { |
| 21 | 23 | Dialog, |
| ... | ... | @@ -28,14 +30,97 @@ import { |
| 28 | 30 | } from "~/components/ui/dialog"; |
| 29 | 31 | import { Group, GroupSeparator } from "~/components/ui/group"; |
| 30 | 32 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| 33 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | |
| 31 | 34 | import { Textarea } from "~/components/ui/textarea"; |
| 32 | 35 | import { toastManager } from "~/components/ui/toast"; |
| 36 | import { | |
| 37 | gitBranchesQueryOptions, | |
| 38 | gitInitMutationOptions, | |
| 39 | gitPullMutationOptions, | |
| 40 | gitRunStackedActionMutationOptions, | |
| 41 | gitStatusQueryOptions, | |
| 42 | invalidateGitQueries, | |
| 43 | } from "~/lib/gitReactQuery"; | |
| 44 | import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; | |
| 33 | 45 | |
| 34 | 46 | interface GitActionsControlProps { |
| 35 | 47 | api: NativeApi | undefined; |
| 36 | 48 | gitCwd: string | null; |
| 37 | 49 | } |
| 38 | 50 | |
| 51 | function getMenuActionDisabledReason( | |
| 52 | item: GitActionMenuItem, | |
| 53 | gitStatus: GitStatusResult | null, | |
| 54 | isBusy: boolean, | |
| 55 | ): string | null { | |
| 56 | if (!item.disabled) return null; | |
| 57 | if (isBusy) return "Git action in progress."; | |
| 58 | if (!gitStatus) return "Git status is unavailable."; | |
| 59 | ||
| 60 | const hasBranch = gitStatus.branch !== null; | |
| 61 | const hasChanges = gitStatus.hasWorkingTreeChanges; | |
| 62 | const hasOpenPr = gitStatus.openPr !== null; | |
| 63 | const isAhead = gitStatus.aheadCount > 0; | |
| 64 | const isBehind = gitStatus.behindCount > 0; | |
| 65 | ||
| 66 | if (item.id === "commit") { | |
| 67 | if (!hasChanges) { | |
| 68 | return "Worktree is clean. Make changes before committing."; | |
| 69 | } | |
| 70 | return "Commit is currently unavailable."; | |
| 71 | } | |
| 72 | ||
| 73 | if (item.id === "push") { | |
| 74 | if (!hasBranch) { | |
| 75 | return "Detached HEAD: checkout a branch before pushing."; | |
| 76 | } | |
| 77 | if (hasChanges) { | |
| 78 | return "Commit or stash local changes before pushing."; | |
| 79 | } | |
| 80 | if (isBehind) { | |
| 81 | return "Branch is behind upstream. Pull/rebase before pushing."; | |
| 82 | } | |
| 83 | if (!isAhead) { | |
| 84 | return "No local commits to push."; | |
| 85 | } | |
| 86 | return "Push is currently unavailable."; | |
| 87 | } | |
| 88 | ||
| 89 | if (hasOpenPr) { | |
| 90 | return "Open PR is currently unavailable."; | |
| 91 | } | |
| 92 | if (!hasBranch) { | |
| 93 | return "Detached HEAD: checkout a branch before creating a PR."; | |
| 94 | } | |
| 95 | if (hasChanges) { | |
| 96 | return "Commit local changes before creating a PR."; | |
| 97 | } | |
| 98 | if (!isAhead) { | |
| 99 | return "No local commits to include in a PR."; | |
| 100 | } | |
| 101 | if (!gitStatus.hasUpstream) { | |
| 102 | return "Set an upstream branch before creating a PR."; | |
| 103 | } | |
| 104 | if (isBehind) { | |
| 105 | return "Branch is behind upstream. Pull/rebase before creating a PR."; | |
| 106 | } | |
| 107 | return "Create PR is currently unavailable."; | |
| 108 | } | |
| 109 | ||
| 110 | function formatUpstreamStatus(input: { | |
| 111 | hasUpstream: boolean; | |
| 112 | aheadCount: number; | |
| 113 | behindCount: number; | |
| 114 | }): string { | |
| 115 | if (!input.hasUpstream) return "No upstream configured"; | |
| 116 | if (input.aheadCount === 0 && input.behindCount === 0) return "Up to date"; | |
| 117 | if (input.aheadCount > 0 && input.behindCount > 0) { | |
| 118 | return `Diverged (+${input.aheadCount} / -${input.behindCount})`; | |
| 119 | } | |
| 120 | if (input.aheadCount > 0) return `Ahead by ${input.aheadCount}`; | |
| 121 | return `Behind by ${input.behindCount}`; | |
| 122 | } | |
| 123 | ||
| 39 | 124 | function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { |
| 40 | 125 | if (icon === "commit") return <GitCommitIcon />; |
| 41 | 126 | if (icon === "push") return <CloudUploadIcon />; |
| ... | ... | @@ -54,21 +139,20 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 54 | 139 | return <InfoIcon className="size-4" />; |
| 55 | 140 | } |
| 56 | 141 | |
| 57 | function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] { | |
| 58 | return ["git", "status", gitCwd]; | |
| 59 | } | |
| 60 | ||
| 61 | function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null) { | |
| 62 | return queryOptions({ | |
| 63 | queryKey: getGitStatusQueryKey(gitCwd), | |
| 64 | queryFn: async () => { | |
| 65 | if (!api || !gitCwd) { | |
| 66 | throw new Error("Git status is unavailable."); | |
| 67 | } | |
| 68 | return api.git.status({ cwd: gitCwd }); | |
| 69 | }, | |
| 70 | enabled: !!api && !!gitCwd, | |
| 71 | }); | |
| 142 | function buildGitActionProgressStages(input: { | |
| 143 | action: GitStackedAction; | |
| 144 | hasCustomCommitMessage: boolean; | |
| 145 | }): string[] { | |
| 146 | const commitStages = input.hasCustomCommitMessage | |
| 147 | ? ["Committing..."] | |
| 148 | : ["Generating commit message...", "Committing..."]; | |
| 149 | if (input.action === "commit") { | |
| 150 | return commitStages; | |
| 151 | } | |
| 152 | if (input.action === "commit_push") { | |
| 153 | return [...commitStages, "Pushing..."]; | |
| 154 | } | |
| 155 | return [...commitStages, "Pushing...", "Creating PR..."]; | |
| 72 | 156 | } |
| 73 | 157 | |
| 74 | 158 | export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { |
| ... | ... | @@ -80,41 +164,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 80 | 164 | gitStatusQueryOptions(api, gitCwd), |
| 81 | 165 | ); |
| 82 | 166 | |
| 83 | const { data: branchList = null } = useQuery({ | |
| 84 | queryKey: ["git", "branches", gitCwd], | |
| 85 | queryFn: async () => { | |
| 86 | if (!api || !gitCwd) throw new Error("Git branches are unavailable."); | |
| 87 | return api.git.listBranches({ cwd: gitCwd }); | |
| 88 | }, | |
| 89 | enabled: !!api && !!gitCwd, | |
| 90 | }); | |
| 167 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd)); | |
| 168 | // Default to true while loading so we don't flash init controls. | |
| 169 | const isRepo = branchList?.isRepo ?? true; | |
| 91 | 170 | |
| 92 | const runImmediateGitActionMutation = useMutation({ | |
| 93 | mutationFn: async ({ | |
| 94 | action, | |
| 95 | commitMessage, | |
| 96 | }: { | |
| 97 | action: GitStackedAction; | |
| 98 | commitMessage?: string; | |
| 99 | }) => { | |
| 100 | if (!api || !gitCwd) { | |
| 101 | throw new Error("Git action is unavailable."); | |
| 102 | } | |
| 103 | return api.git.runStackedAction({ cwd: gitCwd, action, ...(commitMessage ? { commitMessage } : {}) }); | |
| 104 | }, | |
| 105 | onSuccess: async () => { | |
| 106 | await queryClient.invalidateQueries({ queryKey: ["git"] }); | |
| 107 | }, | |
| 108 | }); | |
| 109 | const pullMutation = useMutation({ | |
| 110 | mutationFn: async () => { | |
| 111 | if (!api || !gitCwd) throw new Error("Git pull is unavailable."); | |
| 112 | return api.git.pull({ cwd: gitCwd }); | |
| 113 | }, | |
| 114 | onSuccess: async () => { | |
| 115 | await queryClient.invalidateQueries({ queryKey: ["git"] }); | |
| 116 | }, | |
| 117 | }); | |
| 171 | const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient })); | |
| 172 | ||
| 173 | const runImmediateGitActionMutation = useMutation( | |
| 174 | gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }), | |
| 175 | ); | |
| 176 | const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient })); | |
| 118 | 177 | |
| 119 | 178 | const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; |
| 120 | 179 | const isDefaultBranch = useMemo(() => { |
| ... | ... | @@ -129,13 +188,20 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 129 | 188 | [gitStatus, isGitActionRunning], |
| 130 | 189 | ); |
| 131 | 190 | const quickAction = useMemo( |
| 132 | () => resolveQuickAction(gitStatus, isGitActionRunning), | |
| 133 | [gitStatus, isGitActionRunning], | |
| 191 | () => resolveQuickAction(gitStatus, isGitActionRunning, isDefaultBranch), | |
| 192 | [gitStatus, isDefaultBranch, isGitActionRunning], | |
| 193 | ); | |
| 194 | const quickActionDisabledReason = quickAction.disabled | |
| 195 | ? (quickAction.hint ?? "This action is currently unavailable.") | |
| 196 | : null; | |
| 197 | const [isQuickActionPopoverOpen, setIsQuickActionPopoverOpen] = useState(false); | |
| 198 | const [openDisabledMenuItemId, setOpenDisabledMenuItemId] = useState<GitActionMenuItem["id"] | null>( | |
| 199 | null, | |
| 134 | 200 | ); |
| 135 | 201 | |
| 136 | 202 | const refreshGitStatus = useCallback(async () => { |
| 137 | 203 | if (!api || !gitCwd) return; |
| 138 | await queryClient.invalidateQueries({ queryKey: ["git"] }); | |
| 204 | await invalidateGitQueries(queryClient); | |
| 139 | 205 | }, [api, gitCwd, queryClient]); |
| 140 | 206 | |
| 141 | 207 | const maybeConfirmPushToDefaultBranch = useCallback( |
| ... | ... | @@ -184,31 +250,60 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 184 | 250 | const confirmed = await maybeConfirmPushToDefaultBranch(action); |
| 185 | 251 | if (!confirmed) return; |
| 186 | 252 | |
| 253 | const progressStages = buildGitActionProgressStages({ | |
| 254 | action, | |
| 255 | hasCustomCommitMessage: !!commitMessage?.trim(), | |
| 256 | }); | |
| 257 | const progressToastId = toastManager.add({ | |
| 258 | type: "loading", | |
| 259 | title: progressStages[0] ?? "Running git action...", | |
| 260 | timeout: 0, | |
| 261 | }); | |
| 262 | ||
| 263 | let stageIndex = 0; | |
| 264 | const stageInterval = setInterval(() => { | |
| 265 | stageIndex = Math.min(stageIndex + 1, progressStages.length - 1); | |
| 266 | toastManager.update(progressToastId, { | |
| 267 | title: progressStages[stageIndex] ?? "Running git action...", | |
| 268 | type: "loading", | |
| 269 | timeout: 0, | |
| 270 | }); | |
| 271 | }, 1100); | |
| 272 | ||
| 273 | const stopProgressUpdates = () => { | |
| 274 | clearInterval(stageInterval); | |
| 275 | }; | |
| 276 | ||
| 187 | 277 | const promise = runImmediateGitActionMutation.mutateAsync({ |
| 188 | 278 | action, |
| 189 | 279 | ...(commitMessage ? { commitMessage } : {}), |
| 190 | 280 | }); |
| 191 | toastManager.promise(promise, { | |
| 192 | loading: { | |
| 193 | title: | |
| 194 | action === "commit" | |
| 195 | ? "Committing..." | |
| 196 | : action === "commit_push" | |
| 197 | ? "Pushing..." | |
| 198 | : "Creating PR...", | |
| 199 | }, | |
| 200 | success: (result) => { | |
| 201 | const prUrl = result.pr.url ?? gitStatus?.openPr?.url; | |
| 202 | const shouldOfferCreatePrCta = | |
| 203 | action === "commit_push" && | |
| 204 | !prUrl && | |
| 205 | result.push.status === "pushed"; | |
| 206 | return { | |
| 207 | title: "Done", | |
| 208 | description: describeGitResult(result), | |
| 209 | ...(prUrl | |
| 281 | ||
| 282 | try { | |
| 283 | const result = await promise; | |
| 284 | stopProgressUpdates(); | |
| 285 | ||
| 286 | const prUrl = result.pr.url ?? gitStatus?.openPr?.url; | |
| 287 | const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; | |
| 288 | const shouldOfferOpenPrCta = | |
| 289 | (action === "commit_push" || action === "commit_push_pr") && !!prUrl; | |
| 290 | const shouldOfferCreatePrCta = | |
| 291 | action === "commit_push" && !prUrl && result.push.status === "pushed"; | |
| 292 | ||
| 293 | toastManager.update(progressToastId, { | |
| 294 | type: "success", | |
| 295 | title: "Done", | |
| 296 | description: describeGitResult(result), | |
| 297 | timeout: 10_000, | |
| 298 | ...(shouldOfferPushCta | |
| 299 | ? { | |
| 300 | actionProps: { | |
| 301 | children: "Push", | |
| 302 | onClick: () => setActiveDialogAction("push"), | |
| 303 | }, | |
| 304 | } | |
| 305 | : shouldOfferOpenPrCta | |
| 210 | 306 | ? { |
| 211 | timeout: 10_000, | |
| 212 | 307 | actionProps: { |
| 213 | 308 | children: "Open PR", |
| 214 | 309 | onClick: () => void api?.shell.openExternal(prUrl), |
| ... | ... | @@ -216,21 +311,21 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 216 | 311 | } |
| 217 | 312 | : shouldOfferCreatePrCta |
| 218 | 313 | ? { |
| 219 | timeout: 10_000, | |
| 220 | 314 | actionProps: { |
| 221 | 315 | children: "Create PR", |
| 222 | 316 | onClick: () => setActiveDialogAction("create_pr"), |
| 223 | 317 | }, |
| 224 | 318 | } |
| 225 | 319 | : {}), |
| 226 | }; | |
| 227 | }, | |
| 228 | error: (err) => ({ | |
| 320 | }); | |
| 321 | } catch (err) { | |
| 322 | stopProgressUpdates(); | |
| 323 | toastManager.update(progressToastId, { | |
| 324 | type: "error", | |
| 229 | 325 | title: "Action failed", |
| 230 | 326 | description: err instanceof Error ? err.message : "An error occurred.", |
| 231 | }), | |
| 232 | }); | |
| 233 | void promise.catch(() => undefined); | |
| 327 | }); | |
| 328 | } | |
| 234 | 329 | }, |
| 235 | 330 | [ |
| 236 | 331 | api, |
| ... | ... | @@ -309,6 +404,27 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 309 | 404 | }); |
| 310 | 405 | }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]); |
| 311 | 406 | |
| 407 | const openChangedFileInEditor = useCallback( | |
| 408 | (filePath: string) => { | |
| 409 | if (!api || !gitCwd) { | |
| 410 | toastManager.add({ | |
| 411 | type: "error", | |
| 412 | title: "Editor opening is unavailable.", | |
| 413 | }); | |
| 414 | return; | |
| 415 | } | |
| 416 | const target = resolvePathLinkTarget(filePath, gitCwd); | |
| 417 | void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { | |
| 418 | toastManager.add({ | |
| 419 | type: "error", | |
| 420 | title: "Unable to open file", | |
| 421 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 422 | }); | |
| 423 | }); | |
| 424 | }, | |
| 425 | [api, gitCwd], | |
| 426 | ); | |
| 427 | ||
| 312 | 428 | const dialogTitle = |
| 313 | 429 | activeDialogAction === "commit" |
| 314 | 430 | ? "Commit changes" |
| ... | ... | @@ -321,65 +437,144 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 321 | 437 | : activeDialogAction === "push" |
| 322 | 438 | ? "Push this branch now. If this is the default branch, you'll be asked to confirm." |
| 323 | 439 | : "Create a pull request using generated title/body content."; |
| 440 | const showCompactStatusCard = | |
| 441 | !!gitStatus && gitStatus.hasWorkingTreeChanges && !gitStatus.hasUpstream; | |
| 324 | 442 | |
| 325 | 443 | if (!gitCwd) return null; |
| 326 | 444 | |
| 327 | 445 | return ( |
| 328 | 446 | <> |
| 329 | <Group aria-label="Git actions"> | |
| 447 | {!isRepo ? ( | |
| 330 | 448 | <Button |
| 331 | 449 | variant="outline" |
| 332 | 450 | size="xs" |
| 333 | disabled={isGitActionRunning || quickAction.disabled} | |
| 334 | onClick={runQuickAction} | |
| 451 | disabled={initMutation.isPending} | |
| 452 | onClick={() => initMutation.mutate()} | |
| 335 | 453 | > |
| 336 | <GitQuickActionIcon quickAction={quickAction} /> | |
| 337 | {quickAction.label} | |
| 454 | {initMutation.isPending ? "Initializing..." : "Initialize Git"} | |
| 338 | 455 | </Button> |
| 339 | <GroupSeparator /> | |
| 340 | <Menu | |
| 341 | onOpenChange={(open) => { | |
| 342 | if (open) void refreshGitStatus(); | |
| 343 | }} | |
| 344 | > | |
| 345 | <MenuTrigger | |
| 346 | render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />} | |
| 347 | disabled={isGitActionRunning} | |
| 348 | > | |
| 349 | <ChevronDownIcon aria-hidden="true" className="size-4" /> | |
| 350 | </MenuTrigger> | |
| 351 | <MenuPopup align="end" sideOffset={4}> | |
| 352 | {gitActionMenuItems.map((item) => ( | |
| 353 | <MenuItem | |
| 354 | key={`${item.id}-${item.label}`} | |
| 355 | disabled={item.disabled} | |
| 356 | onClick={() => { | |
| 357 | openDialogForMenuItem(item); | |
| 358 | }} | |
| 456 | ) : ( | |
| 457 | <Group aria-label="Git actions"> | |
| 458 | {quickActionDisabledReason ? ( | |
| 459 | <Popover open={isQuickActionPopoverOpen} onOpenChange={setIsQuickActionPopoverOpen}> | |
| 460 | <PopoverTrigger | |
| 461 | render={ | |
| 462 | <span | |
| 463 | className="inline-flex cursor-not-allowed" | |
| 464 | onBlur={() => setIsQuickActionPopoverOpen(false)} | |
| 465 | onFocus={() => setIsQuickActionPopoverOpen(true)} | |
| 466 | onMouseEnter={() => setIsQuickActionPopoverOpen(true)} | |
| 467 | onMouseLeave={() => setIsQuickActionPopoverOpen(false)} | |
| 468 | tabIndex={0} | |
| 469 | /> | |
| 470 | } | |
| 359 | 471 | > |
| 360 | <GitActionItemIcon icon={item.icon} /> | |
| 361 | {item.label} | |
| 362 | </MenuItem> | |
| 363 | ))} | |
| 364 | {gitStatus?.branch === null && ( | |
| 365 | <p className="px-2 py-1.5 text-xs text-warning"> | |
| 366 | Detached HEAD: create and checkout a branch to enable push and PR actions. | |
| 367 | </p> | |
| 368 | )} | |
| 369 | {gitStatus && | |
| 370 | gitStatus.branch !== null && | |
| 371 | !gitStatus.hasWorkingTreeChanges && | |
| 372 | gitStatus.behindCount > 0 && ( | |
| 472 | <Button | |
| 473 | variant="outline" | |
| 474 | size="xs" | |
| 475 | disabled={isGitActionRunning || quickAction.disabled} | |
| 476 | className="cursor-not-allowed disabled:cursor-not-allowed" | |
| 477 | onClick={runQuickAction} | |
| 478 | > | |
| 479 | <GitQuickActionIcon quickAction={quickAction} /> | |
| 480 | {quickAction.label} | |
| 481 | </Button> | |
| 482 | </PopoverTrigger> | |
| 483 | <PopoverPopup tooltipStyle side="bottom" align="start"> | |
| 484 | {quickActionDisabledReason} | |
| 485 | </PopoverPopup> | |
| 486 | </Popover> | |
| 487 | ) : ( | |
| 488 | <Button | |
| 489 | variant="outline" | |
| 490 | size="xs" | |
| 491 | disabled={isGitActionRunning || quickAction.disabled} | |
| 492 | onClick={runQuickAction} | |
| 493 | > | |
| 494 | <GitQuickActionIcon quickAction={quickAction} /> | |
| 495 | {quickAction.label} | |
| 496 | </Button> | |
| 497 | )} | |
| 498 | <GroupSeparator /> | |
| 499 | <Menu | |
| 500 | onOpenChange={(open) => { | |
| 501 | if (open) void refreshGitStatus(); | |
| 502 | if (!open) setOpenDisabledMenuItemId(null); | |
| 503 | }} | |
| 504 | > | |
| 505 | <MenuTrigger | |
| 506 | render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />} | |
| 507 | disabled={isGitActionRunning} | |
| 508 | > | |
| 509 | <ChevronDownIcon aria-hidden="true" className="size-4" /> | |
| 510 | </MenuTrigger> | |
| 511 | <MenuPopup align="end" sideOffset={4}> | |
| 512 | {gitActionMenuItems.map((item) => { | |
| 513 | const disabledReason = getMenuActionDisabledReason(item, gitStatus, isGitActionRunning); | |
| 514 | if (item.disabled && disabledReason) { | |
| 515 | return ( | |
| 516 | <Popover | |
| 517 | key={`${item.id}-${item.label}`} | |
| 518 | open={openDisabledMenuItemId === item.id} | |
| 519 | onOpenChange={(open) => setOpenDisabledMenuItemId(open ? item.id : null)} | |
| 520 | > | |
| 521 | <PopoverTrigger | |
| 522 | render={ | |
| 523 | <span | |
| 524 | className="inline-flex w-full cursor-not-allowed" | |
| 525 | onBlur={() => setOpenDisabledMenuItemId(null)} | |
| 526 | onFocus={() => setOpenDisabledMenuItemId(item.id)} | |
| 527 | onMouseEnter={() => setOpenDisabledMenuItemId(item.id)} | |
| 528 | onMouseLeave={() => setOpenDisabledMenuItemId(null)} | |
| 529 | tabIndex={0} | |
| 530 | /> | |
| 531 | } | |
| 532 | > | |
| 533 | <MenuItem disabled> | |
| 534 | <GitActionItemIcon icon={item.icon} /> | |
| 535 | {item.label} | |
| 536 | </MenuItem> | |
| 537 | </PopoverTrigger> | |
| 538 | <PopoverPopup tooltipStyle side="left" align="center"> | |
| 539 | {disabledReason} | |
| 540 | </PopoverPopup> | |
| 541 | </Popover> | |
| 542 | ); | |
| 543 | } | |
| 544 | ||
| 545 | return ( | |
| 546 | <MenuItem | |
| 547 | key={`${item.id}-${item.label}`} | |
| 548 | disabled={item.disabled} | |
| 549 | onClick={() => { | |
| 550 | openDialogForMenuItem(item); | |
| 551 | }} | |
| 552 | > | |
| 553 | <GitActionItemIcon icon={item.icon} /> | |
| 554 | {item.label} | |
| 555 | </MenuItem> | |
| 556 | ); | |
| 557 | })} | |
| 558 | {gitStatus?.branch === null && ( | |
| 373 | 559 | <p className="px-2 py-1.5 text-xs text-warning"> |
| 374 | Behind upstream. Pull/rebase first. | |
| 560 | Detached HEAD: create and checkout a branch to enable push and PR actions. | |
| 375 | 561 | </p> |
| 376 | 562 | )} |
| 377 | {gitStatusError && ( | |
| 378 | <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p> | |
| 379 | )} | |
| 380 | </MenuPopup> | |
| 381 | </Menu> | |
| 382 | </Group> | |
| 563 | {gitStatus && | |
| 564 | gitStatus.branch !== null && | |
| 565 | !gitStatus.hasWorkingTreeChanges && | |
| 566 | gitStatus.behindCount > 0 && ( | |
| 567 | <p className="px-2 py-1.5 text-xs text-warning"> | |
| 568 | Behind upstream. Pull/rebase first. | |
| 569 | </p> | |
| 570 | )} | |
| 571 | {gitStatusError && ( | |
| 572 | <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p> | |
| 573 | )} | |
| 574 | </MenuPopup> | |
| 575 | </Menu> | |
| 576 | </Group> | |
| 577 | )} | |
| 383 | 578 | |
| 384 | 579 | <Dialog |
| 385 | 580 | open={activeDialogAction !== null} |
| ... | ... | @@ -396,19 +591,72 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 396 | 591 | <DialogDescription>{dialogDescription}</DialogDescription> |
| 397 | 592 | </DialogHeader> |
| 398 | 593 | <DialogPanel className="space-y-4"> |
| 399 | <div className="rounded-lg border border-input bg-muted/40 p-3 text-xs"> | |
| 400 | <p> | |
| 401 | <span className="font-medium">Branch:</span>{" "} | |
| 402 | {gitStatus?.branch ?? "(detached HEAD)"} | |
| 403 | </p> | |
| 404 | <p> | |
| 405 | <span className="font-medium">Working tree:</span>{" "} | |
| 406 | {gitStatus?.hasWorkingTreeChanges ? "Has changes" : "Clean"} | |
| 407 | </p> | |
| 408 | <p> | |
| 409 | <span className="font-medium">Ahead/behind:</span>{" "} | |
| 410 | {gitStatus ? `${gitStatus.aheadCount}/${gitStatus.behindCount}` : "unknown"} | |
| 411 | </p> | |
| 594 | <div className="space-y-3 rounded-lg border border-input bg-muted/40 p-3 text-xs"> | |
| 595 | <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1"> | |
| 596 | <span className="text-muted-foreground">Branch</span> | |
| 597 | <span className="font-medium">{gitStatus?.branch ?? "(detached HEAD)"}</span> | |
| 598 | {!showCompactStatusCard && ( | |
| 599 | <> | |
| 600 | <span className="text-muted-foreground">Upstream</span> | |
| 601 | <span className="font-medium"> | |
| 602 | {gitStatus | |
| 603 | ? formatUpstreamStatus({ | |
| 604 | hasUpstream: gitStatus.hasUpstream, | |
| 605 | aheadCount: gitStatus.aheadCount, | |
| 606 | behindCount: gitStatus.behindCount, | |
| 607 | }) | |
| 608 | : "unknown"} | |
| 609 | </span> | |
| 610 | <span className="text-muted-foreground">Working tree</span> | |
| 611 | <span className="font-medium"> | |
| 612 | {!gitStatus || !gitStatus.hasWorkingTreeChanges | |
| 613 | ? "Clean" | |
| 614 | : `${gitStatus.workingTree.files.length} file(s)`} | |
| 615 | </span> | |
| 616 | <span className="text-muted-foreground">Diff</span> | |
| 617 | <span className="font-mono"> | |
| 618 | {!gitStatus || !gitStatus.hasWorkingTreeChanges ? ( | |
| 619 | "none" | |
| 620 | ) : ( | |
| 621 | <> | |
| 622 | <span className="text-success">+{gitStatus.workingTree.insertions}</span> | |
| 623 | <span className="text-muted-foreground"> / </span> | |
| 624 | <span className="text-destructive">-{gitStatus.workingTree.deletions}</span> | |
| 625 | </> | |
| 626 | )} | |
| 627 | </span> | |
| 628 | </> | |
| 629 | )} | |
| 630 | </div> | |
| 631 | <div className="space-y-1"> | |
| 632 | <p className="text-muted-foreground">Files</p> | |
| 633 | {!gitStatus || gitStatus.workingTree.files.length === 0 ? ( | |
| 634 | <p className="font-medium">none</p> | |
| 635 | ) : ( | |
| 636 | <div className="space-y-1"> | |
| 637 | {gitStatus.workingTree.files.slice(0, 6).map((file) => ( | |
| 638 | <button | |
| 639 | type="button" | |
| 640 | key={file.path} | |
| 641 | className="flex w-full items-center justify-between gap-3 rounded-md border border-input bg-background px-2 py-1 font-mono text-left transition-colors hover:bg-accent/50" | |
| 642 | onClick={() => openChangedFileInEditor(file.path)} | |
| 643 | > | |
| 644 | <span className="truncate">{file.path}</span> | |
| 645 | <span className="shrink-0"> | |
| 646 | <span className="text-success">+{file.insertions}</span> | |
| 647 | <span className="text-muted-foreground"> / </span> | |
| 648 | <span className="text-destructive">-{file.deletions}</span> | |
| 649 | </span> | |
| 650 | </button> | |
| 651 | ))} | |
| 652 | {gitStatus.workingTree.files.length > 6 && ( | |
| 653 | <Badge variant="outline" size="sm"> | |
| 654 | +{gitStatus.workingTree.files.length - 6} more | |
| 655 | </Badge> | |
| 656 | )} | |
| 657 | </div> | |
| 658 | )} | |
| 659 | </div> | |
| 412 | 660 | </div> |
| 413 | 661 | {activeDialogAction === "commit" && ( |
| 414 | 662 | <div className="space-y-1"> |
apps/web/src/components/ui/toast.tsx+4-4| ... | ... | @@ -37,7 +37,7 @@ interface ToastProviderProps extends Toast.Provider.Props { |
| 37 | 37 | |
| 38 | 38 | function ToastProvider({ |
| 39 | 39 | children, |
| 40 | position = "bottom-right", | |
| 40 | position = "top-right", | |
| 41 | 41 | ...props |
| 42 | 42 | }: ToastProviderProps) { |
| 43 | 43 | return ( |
| ... | ... | @@ -48,7 +48,7 @@ function ToastProvider({ |
| 48 | 48 | ); |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | function Toasts({ position = "bottom-right" }: { position: ToastPosition }) { | |
| 51 | function Toasts({ position = "top-right" }: { position: ToastPosition }) { | |
| 52 | 52 | const { toasts } = Toast.useToastManager(); |
| 53 | 53 | const isTop = position.startsWith("top"); |
| 54 | 54 | |
| ... | ... | @@ -56,9 +56,9 @@ function Toasts({ position = "bottom-right" }: { position: ToastPosition }) { |
| 56 | 56 | <Toast.Portal data-slot="toast-portal"> |
| 57 | 57 | <Toast.Viewport |
| 58 | 58 | className={cn( |
| 59 | "fixed z-50 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]", | |
| 59 | "fixed z-50 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-header-offset:52px] [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]", | |
| 60 | 60 | // Vertical positioning |
| 61 | "data-[position*=top]:top-(--toast-inset)", | |
| 61 | "data-[position*=top]:top-[calc(var(--toast-inset)+var(--toast-header-offset))]", | |
| 62 | 62 | "data-[position*=bottom]:bottom-(--toast-inset)", |
| 63 | 63 | // Horizontal positioning |
| 64 | 64 | "data-[position*=left]:left-(--toast-inset)", |
apps/web/src/lib/gitReactQuery.ts created+131| ... | ... | @@ -0,0 +1,131 @@ |
| 1 | import type { GitStackedAction, NativeApi } from "@t3tools/contracts"; | |
| 2 | import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; | |
| 3 | ||
| 4 | export const gitQueryKeys = { | |
| 5 | all: ["git"] as const, | |
| 6 | status: (cwd: string | null) => ["git", "status", cwd] as const, | |
| 7 | branches: (cwd: string | null) => ["git", "branches", cwd] as const, | |
| 8 | }; | |
| 9 | ||
| 10 | export function invalidateGitQueries(queryClient: QueryClient) { | |
| 11 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); | |
| 12 | } | |
| 13 | ||
| 14 | export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | null) { | |
| 15 | return queryOptions({ | |
| 16 | queryKey: gitQueryKeys.status(cwd), | |
| 17 | queryFn: async () => { | |
| 18 | if (!api || !cwd) { | |
| 19 | throw new Error("Git status is unavailable."); | |
| 20 | } | |
| 21 | return api.git.status({ cwd }); | |
| 22 | }, | |
| 23 | enabled: !!api && !!cwd, | |
| 24 | }); | |
| 25 | } | |
| 26 | ||
| 27 | export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string | null) { | |
| 28 | return queryOptions({ | |
| 29 | queryKey: gitQueryKeys.branches(cwd), | |
| 30 | queryFn: async () => { | |
| 31 | if (!api || !cwd) { | |
| 32 | throw new Error("Git branches are unavailable."); | |
| 33 | } | |
| 34 | return api.git.listBranches({ cwd }); | |
| 35 | }, | |
| 36 | enabled: !!api && !!cwd, | |
| 37 | }); | |
| 38 | } | |
| 39 | ||
| 40 | export function gitInitMutationOptions(input: { | |
| 41 | api: NativeApi | undefined; | |
| 42 | cwd: string | null; | |
| 43 | queryClient: QueryClient; | |
| 44 | }) { | |
| 45 | return mutationOptions({ | |
| 46 | mutationFn: async () => { | |
| 47 | if (!input.api || !input.cwd) throw new Error("Git init is unavailable."); | |
| 48 | return input.api.git.init({ cwd: input.cwd }); | |
| 49 | }, | |
| 50 | onSuccess: async () => { | |
| 51 | await invalidateGitQueries(input.queryClient); | |
| 52 | }, | |
| 53 | }); | |
| 54 | } | |
| 55 | ||
| 56 | export function gitCheckoutMutationOptions(input: { | |
| 57 | api: NativeApi | undefined; | |
| 58 | cwd: string | null; | |
| 59 | queryClient: QueryClient; | |
| 60 | }) { | |
| 61 | return mutationOptions({ | |
| 62 | mutationFn: async (branch: string) => { | |
| 63 | if (!input.api || !input.cwd) throw new Error("Git checkout is unavailable."); | |
| 64 | return input.api.git.checkout({ cwd: input.cwd, branch }); | |
| 65 | }, | |
| 66 | onSuccess: async () => { | |
| 67 | await invalidateGitQueries(input.queryClient); | |
| 68 | }, | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | export function gitCreateBranchAndCheckoutMutationOptions(input: { | |
| 73 | api: NativeApi | undefined; | |
| 74 | cwd: string | null; | |
| 75 | queryClient: QueryClient; | |
| 76 | }) { | |
| 77 | return mutationOptions({ | |
| 78 | mutationFn: async (branch: string) => { | |
| 79 | if (!input.api || !input.cwd) throw new Error("Git branch creation is unavailable."); | |
| 80 | await input.api.git.createBranch({ cwd: input.cwd, branch }); | |
| 81 | return input.api.git.checkout({ cwd: input.cwd, branch }); | |
| 82 | }, | |
| 83 | onSuccess: async () => { | |
| 84 | await invalidateGitQueries(input.queryClient); | |
| 85 | }, | |
| 86 | }); | |
| 87 | } | |
| 88 | ||
| 89 | export function gitRunStackedActionMutationOptions(input: { | |
| 90 | api: NativeApi | undefined; | |
| 91 | cwd: string | null; | |
| 92 | queryClient: QueryClient; | |
| 93 | }) { | |
| 94 | return mutationOptions({ | |
| 95 | mutationFn: async ({ | |
| 96 | action, | |
| 97 | commitMessage, | |
| 98 | }: { | |
| 99 | action: GitStackedAction; | |
| 100 | commitMessage?: string; | |
| 101 | }) => { | |
| 102 | if (!input.api || !input.cwd) { | |
| 103 | throw new Error("Git action is unavailable."); | |
| 104 | } | |
| 105 | return input.api.git.runStackedAction({ | |
| 106 | cwd: input.cwd, | |
| 107 | action, | |
| 108 | ...(commitMessage ? { commitMessage } : {}), | |
| 109 | }); | |
| 110 | }, | |
| 111 | onSuccess: async () => { | |
| 112 | await invalidateGitQueries(input.queryClient); | |
| 113 | }, | |
| 114 | }); | |
| 115 | } | |
| 116 | ||
| 117 | export function gitPullMutationOptions(input: { | |
| 118 | api: NativeApi | undefined; | |
| 119 | cwd: string | null; | |
| 120 | queryClient: QueryClient; | |
| 121 | }) { | |
| 122 | return mutationOptions({ | |
| 123 | mutationFn: async () => { | |
| 124 | if (!input.api || !input.cwd) throw new Error("Git pull is unavailable."); | |
| 125 | return input.api.git.pull({ cwd: input.cwd }); | |
| 126 | }, | |
| 127 | onSuccess: async () => { | |
| 128 | await invalidateGitQueries(input.queryClient); | |
| 129 | }, | |
| 130 | }); | |
| 131 | } |
packages/contracts/src/git.test.ts+10| ... | ... | @@ -25,6 +25,14 @@ describe("git contracts", () => { |
| 25 | 25 | const parsed = gitStatusResultSchema.parse({ |
| 26 | 26 | branch: "feature/git-actions", |
| 27 | 27 | hasWorkingTreeChanges: true, |
| 28 | workingTree: { | |
| 29 | files: [ | |
| 30 | { path: "src/git.ts", insertions: 8, deletions: 2 }, | |
| 31 | { path: "src/git.test.ts", insertions: 4, deletions: 1 }, | |
| 32 | ], | |
| 33 | insertions: 12, | |
| 34 | deletions: 3, | |
| 35 | }, | |
| 28 | 36 | hasUpstream: false, |
| 29 | 37 | aheadCount: 0, |
| 30 | 38 | behindCount: 0, |
| ... | ... | @@ -32,6 +40,8 @@ describe("git contracts", () => { |
| 32 | 40 | }); |
| 33 | 41 | expect(parsed.branch).toBe("feature/git-actions"); |
| 34 | 42 | expect(parsed.hasWorkingTreeChanges).toBe(true); |
| 43 | expect(parsed.workingTree.files[0]?.insertions).toBe(8); | |
| 44 | expect(parsed.workingTree.files).toHaveLength(2); | |
| 35 | 45 | }); |
| 36 | 46 | |
| 37 | 47 | it("accepts supported stacked actions", () => { |
packages/contracts/src/git.ts+11| ... | ... | @@ -75,6 +75,17 @@ export const gitPullInputSchema = z.object({ |
| 75 | 75 | export const gitStatusResultSchema = z.object({ |
| 76 | 76 | branch: z.string().min(1).nullable(), |
| 77 | 77 | hasWorkingTreeChanges: z.boolean(), |
| 78 | workingTree: z.object({ | |
| 79 | files: z.array( | |
| 80 | z.object({ | |
| 81 | path: z.string().min(1), | |
| 82 | insertions: z.number().int().nonnegative(), | |
| 83 | deletions: z.number().int().nonnegative(), | |
| 84 | }), | |
| 85 | ), | |
| 86 | insertions: z.number().int().nonnegative(), | |
| 87 | deletions: z.number().int().nonnegative(), | |
| 88 | }), | |
| 78 | 89 | hasUpstream: z.boolean(), |
| 79 | 90 | aheadCount: z.number().int().nonnegative(), |
| 80 | 91 | behindCount: z.number().int().nonnegative(), |