| 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,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 | function commandLabel(args: readonly string[]): string { | 142 | function commandLabel(args: readonly string[]): string { |
| 98 | return `git ${args.join(" ")}`; | 143 | return `git ${args.join(" ")}`; |
| 99 | } | 144 | } |
| ... | @@ -176,6 +221,7 @@ export class GitCoreService { | ... | @@ -176,6 +221,7 @@ export class GitCoreService { |
| 176 | return gitStatusResultSchema.parse({ | 221 | return gitStatusResultSchema.parse({ |
| 177 | branch: details.branch, | 222 | branch: details.branch, |
| 178 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | 223 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 224 | workingTree: details.workingTree, | ||
| 179 | hasUpstream: details.hasUpstream, | 225 | hasUpstream: details.hasUpstream, |
| 180 | aheadCount: details.aheadCount, | 226 | aheadCount: details.aheadCount, |
| 181 | behindCount: details.behindCount, | 227 | behindCount: details.behindCount, |
| ... | @@ -184,15 +230,20 @@ export class GitCoreService { | ... | @@ -184,15 +230,20 @@ export class GitCoreService { |
| 184 | } | 230 | } |
| 185 | 231 | ||
| 186 | async statusDetails(cwd: string): Promise<GitStatusDetails> { | 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 | let branch: string | null = null; | 239 | let branch: string | null = null; |
| 190 | let upstreamRef: string | null = null; | 240 | let upstreamRef: string | null = null; |
| 191 | let aheadCount = 0; | 241 | let aheadCount = 0; |
| 192 | let behindCount = 0; | 242 | let behindCount = 0; |
| 193 | let hasWorkingTreeChanges = false; | 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 | if (line.startsWith("# branch.head ")) { | 247 | if (line.startsWith("# branch.head ")) { |
| 197 | const value = line.slice("# branch.head ".length).trim(); | 248 | const value = line.slice("# branch.head ".length).trim(); |
| 198 | branch = value.startsWith("(") ? null : value; | 249 | branch = value.startsWith("(") ? null : value; |
| ... | @@ -212,13 +263,45 @@ export class GitCoreService { | ... | @@ -212,13 +263,45 @@ export class GitCoreService { |
| 212 | } | 263 | } |
| 213 | if (line.trim().length > 0 && !line.startsWith("#")) { | 264 | if (line.trim().length > 0 && !line.startsWith("#")) { |
| 214 | hasWorkingTreeChanges = true; | 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 | return { | 296 | return { |
| 219 | branch, | 297 | branch, |
| 220 | upstreamRef, | 298 | upstreamRef, |
| 221 | hasWorkingTreeChanges, | 299 | hasWorkingTreeChanges, |
| 300 | workingTree: { | ||
| 301 | files, | ||
| 302 | insertions, | ||
| 303 | deletions, | ||
| 304 | }, | ||
| 222 | hasUpstream: upstreamRef !== null, | 305 | hasUpstream: upstreamRef !== null, |
| 223 | aheadCount, | 306 | aheadCount, |
| 224 | behindCount, | 307 | behindCount, |
apps/server/src/gitManager.ts+1| ... | @@ -218,6 +218,7 @@ export class GitManager { | ... | @@ -218,6 +218,7 @@ export class GitManager { |
| 218 | return { | 218 | return { |
| 219 | branch: details.branch, | 219 | branch: details.branch, |
| 220 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | 220 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 221 | workingTree: details.workingTree, | ||
| 221 | hasUpstream: details.hasUpstream, | 222 | hasUpstream: details.hasUpstream, |
| 222 | aheadCount: details.aheadCount, | 223 | aheadCount: details.aheadCount, |
| 223 | behindCount: details.behindCount, | 224 | behindCount: details.behindCount, |
apps/server/src/wsServer.test.ts+10| ... | @@ -557,6 +557,11 @@ describe("WebSocket Server", () => { | ... | @@ -557,6 +557,11 @@ describe("WebSocket Server", () => { |
| 557 | status: vi.fn().mockResolvedValue({ | 557 | status: vi.fn().mockResolvedValue({ |
| 558 | branch: "feature/test", | 558 | branch: "feature/test", |
| 559 | hasWorkingTreeChanges: true, | 559 | hasWorkingTreeChanges: true, |
| 560 | workingTree: { | ||
| 561 | files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], | ||
| 562 | insertions: 7, | ||
| 563 | deletions: 2, | ||
| 564 | }, | ||
| 560 | hasUpstream: false, | 565 | hasUpstream: false, |
| 561 | aheadCount: 0, | 566 | aheadCount: 0, |
| 562 | behindCount: 0, | 567 | behindCount: 0, |
| ... | @@ -581,6 +586,11 @@ describe("WebSocket Server", () => { | ... | @@ -581,6 +586,11 @@ describe("WebSocket Server", () => { |
| 581 | expect(response.result).toEqual({ | 586 | expect(response.result).toEqual({ |
| 582 | branch: "feature/test", | 587 | branch: "feature/test", |
| 583 | hasWorkingTreeChanges: true, | 588 | hasWorkingTreeChanges: true, |
| 589 | workingTree: { | ||
| 590 | files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], | ||
| 591 | insertions: 7, | ||
| 592 | deletions: 2, | ||
| 593 | }, | ||
| 584 | hasUpstream: false, | 594 | hasUpstream: false, |
| 585 | aheadCount: 0, | 595 | aheadCount: 0, |
| 586 | behindCount: 0, | 596 | behindCount: 0, |
apps/web/src/components/BranchToolbar.tsx+118-152| ... | @@ -3,6 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ... | @@ -3,6 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useEffect, useState } from "react"; | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | ||
| 5 | import { useNativeApi } from "../hooks/useNativeApi"; | 5 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 6 | import { | ||
| 7 | gitBranchesQueryOptions, | ||
| 8 | gitCheckoutMutationOptions, | ||
| 9 | gitCreateBranchAndCheckoutMutationOptions, | ||
| 10 | } from "../lib/gitReactQuery"; | ||
| 6 | import { useStore } from "../store"; | 11 | import { useStore } from "../store"; |
| 7 | import { Button } from "./ui/button"; | 12 | import { Button } from "./ui/button"; |
| 8 | import { | 13 | import { |
| ... | @@ -37,28 +42,19 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B | ... | @@ -37,28 +42,19 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 37 | const activeThreadId = activeThread?.id; | 42 | const activeThreadId = activeThread?.id; |
| 38 | const activeThreadBranch = activeThread?.branch ?? null; | 43 | const activeThreadBranch = activeThread?.branch ?? null; |
| 39 | const activeWorktreePath = activeThread?.worktreePath ?? null; | 44 | const activeWorktreePath = activeThread?.worktreePath ?? null; |
| 40 | const branchCwd = activeWorktreePath ?? activeProject?.cwd; | 45 | const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null; |
| 41 | 46 | ||
| 42 | // ── Queries ─────────────────────────────────────────────────────────── | 47 | // ── Queries ─────────────────────────────────────────────────────────── |
| 43 | 48 | ||
| 44 | const branchesQuery = useQuery({ | 49 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, branchCwd)); |
| 45 | queryKey: ["git", "branches", branchCwd], | ||
| 46 | queryFn: () => api!.git.listBranches({ cwd: branchCwd! }), | ||
| 47 | enabled: !!api && !!branchCwd, | ||
| 48 | }); | ||
| 49 | 50 | ||
| 50 | const branches = branchesQuery.data?.branches ?? []; | 51 | const branches = branchesQuery.data?.branches ?? []; |
| 51 | const branchNames = branches.map((branch) => branch.name); | 52 | const branchNames = branches.map((branch) => branch.name); |
| 52 | const branchByName = new Map(branches.map((branch) => [branch.name, branch])); | 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 | // ── Mutations ───────────────────────────────────────────────────────── | 54 | // ── Mutations ───────────────────────────────────────────────────────── |
| 58 | 55 | ||
| 59 | const checkoutMutation = useMutation({ | 56 | const checkoutMutation = useMutation({ |
| 60 | mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }), | 57 | ...gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), |
| 61 | onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }), | ||
| 62 | onError: (error) => { | 58 | onError: (error) => { |
| 63 | setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."); | 59 | setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."); |
| 64 | setIsBranchMenuOpen(true); | 60 | setIsBranchMenuOpen(true); |
| ... | @@ -66,25 +62,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B | ... | @@ -66,25 +62,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 66 | }); | 62 | }); |
| 67 | 63 | ||
| 68 | const createBranchMutation = useMutation({ | 64 | const createBranchMutation = useMutation({ |
| 69 | mutationFn: (branch: string) => | 65 | ...gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), |
| 70 | api!.git | ||
| 71 | .createBranch({ cwd: branchCwd!, branch }) | ||
| 72 | .then(() => api!.git.checkout({ cwd: branchCwd!, branch })), | ||
| 73 | onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }), | ||
| 74 | onError: (error) => | 66 | onError: (error) => |
| 75 | setThreadError(error instanceof Error ? error.message : "Failed to create branch."), | 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 | // ── Effects ─────────────────────────────────────────────────────────── | 70 | // ── Effects ─────────────────────────────────────────────────────────── |
| 89 | 71 | ||
| 90 | // Keep thread branch synced to git current branch for local threads. | 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,134 +176,118 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 194 | )} | 176 | )} |
| 195 | </div> | 177 | </div> |
| 196 | 178 | ||
| 197 | {!isRepo ? ( | 179 | <Combobox |
| 198 | <button | 180 | items={branchNames} |
| 199 | type="button" | 181 | autoHighlight |
| 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" | 182 | onOpenChange={(open) => setIsBranchMenuOpen(open)} |
| 201 | disabled={!branchCwd || initMutation.isPending} | 183 | open={isBranchMenuOpen} |
| 202 | onClick={() => initMutation.mutate()} | 184 | value={activeThread.branch} |
| 203 | > | 185 | > |
| 204 | {initMutation.isPending ? "Initializing\u2026" : "Initialize git"} | 186 | <ComboboxTrigger |
| 205 | </button> | 187 | render={<Button variant="ghost" size="xs" />} |
| 206 | ) : ( | 188 | className="text-muted-foreground/70 hover:text-foreground/80" |
| 207 | <Combobox | 189 | disabled={branchesQuery.isLoading} |
| 208 | items={branchNames} | ||
| 209 | autoHighlight | ||
| 210 | onOpenChange={(open) => setIsBranchMenuOpen(open)} | ||
| 211 | open={isBranchMenuOpen} | ||
| 212 | value={activeThread.branch} | ||
| 213 | > | 190 | > |
| 214 | <ComboboxTrigger | 191 | <span className="max-w-[240px] truncate"> |
| 215 | render={<Button variant="ghost" size="xs" />} | 192 | {activeThread.branch |
| 216 | className="text-muted-foreground/70 hover:text-foreground/80" | 193 | ? envMode === "worktree" && !activeWorktreePath |
| 217 | disabled={branchesQuery.isLoading} | 194 | ? `From ${activeThread.branch}` |
| 218 | > | 195 | : activeThread.branch |
| 219 | <span className="max-w-[240px] truncate"> | 196 | : "Select branch"} |
| 220 | {activeThread.branch | 197 | </span> |
| 221 | ? envMode === "worktree" && !activeWorktreePath | 198 | <ChevronDownIcon /> |
| 222 | ? `From ${activeThread.branch}` | 199 | </ComboboxTrigger> |
| 223 | : activeThread.branch | 200 | <ComboboxPopup align="end" side="top" className="w-64"> |
| 224 | : "Select branch"} | 201 | <div className="border-b"> |
| 225 | </span> | 202 | <ComboboxInput |
| 226 | <ChevronDownIcon /> | 203 | className="rounded-b-none before:rounded-b-none [&_input]:font-sans" |
| 227 | </ComboboxTrigger> | 204 | placeholder="Search branches..." |
| 228 | <ComboboxPopup align="end" side="top" className="w-64"> | 205 | showClear |
| 229 | <div className="border-b"> | 206 | showTrigger={false} |
| 230 | <ComboboxInput | 207 | size="sm" |
| 231 | className="rounded-b-none before:rounded-b-none [&_input]:font-sans" | 208 | /> |
| 232 | placeholder="Search branches..." | 209 | </div> |
| 233 | showClear | 210 | <ComboboxEmpty>No branches found.</ComboboxEmpty> |
| 234 | showTrigger={false} | ||
| 235 | size="sm" | ||
| 236 | /> | ||
| 237 | </div> | ||
| 238 | <ComboboxEmpty>No branches found.</ComboboxEmpty> | ||
| 239 | 211 | ||
| 240 | <ComboboxList className="max-h-56"> | 212 | <ComboboxList className="max-h-56"> |
| 241 | {(branchName) => { | 213 | {(branchName) => { |
| 242 | const branch = branchByName.get(branchName); | 214 | const branch = branchByName.get(branchName); |
| 243 | if (!branch) return null; | 215 | if (!branch) return null; |
| 244 | 216 | ||
| 245 | const hasSecondaryWorktree = | 217 | const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProject.cwd; |
| 246 | branch.worktreePath && branch.worktreePath !== activeProject.cwd; | 218 | return ( |
| 247 | return ( | 219 | <ComboboxItem |
| 248 | <ComboboxItem | 220 | hideIndicator |
| 249 | hideIndicator | 221 | key={branchName} |
| 250 | key={branchName} | 222 | value={branchName} |
| 251 | value={branchName} | 223 | className={branchName === activeThread.branch ? "bg-accent text-foreground" : undefined} |
| 252 | className={branchName === activeThread.branch ? "bg-accent text-foreground" : undefined} | 224 | onClick={() => selectBranch(branch)} |
| 253 | onClick={() => selectBranch(branch)} | 225 | > |
| 254 | > | 226 | <div className="flex w-full items-center justify-between gap-2"> |
| 255 | <div className="flex w-full items-center justify-between gap-2"> | 227 | <span className="truncate">{branchName}</span> |
| 256 | <span className="truncate">{branchName}</span> | 228 | {(branch.current || branch.isDefault || hasSecondaryWorktree) && ( |
| 257 | {(branch.current || branch.isDefault || hasSecondaryWorktree) && ( | 229 | <span className="shrink-0 text-[10px] text-muted-foreground/45"> |
| 258 | <span className="shrink-0 text-[10px] text-muted-foreground/45"> | 230 | {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"} |
| 259 | {branch.current | 231 | </span> |
| 260 | ? "current" | 232 | )} |
| 261 | : hasSecondaryWorktree | 233 | </div> |
| 262 | ? "worktree" | 234 | </ComboboxItem> |
| 263 | : "default"} | 235 | ); |
| 264 | </span> | 236 | }} |
| 265 | )} | 237 | </ComboboxList> |
| 266 | </div> | 238 | {envMode === "local" && ( |
| 267 | </ComboboxItem> | 239 | <> |
| 268 | ); | 240 | {isCreatingBranch ? ( |
| 269 | }} | 241 | <form |
| 270 | </ComboboxList> | 242 | onSubmit={(event) => { |
| 271 | {envMode === "local" && ( | 243 | event.preventDefault(); |
| 272 | <> | 244 | createBranch(); |
| 273 | {isCreatingBranch ? ( | 245 | }} |
| 274 | <form | 246 | > |
| 275 | onSubmit={(event) => { | 247 | <InputGroup className="rounded-t-none before:rounded-t-none"> |
| 276 | event.preventDefault(); | 248 | <InputGroupInput |
| 277 | createBranch(); | 249 | className="[&_input]:font-sans" |
| 278 | }} | 250 | size="sm" |
| 279 | > | 251 | placeholder="branch-name" |
| 280 | <InputGroup className="rounded-t-none before:rounded-t-none"> | 252 | type="text" |
| 281 | <InputGroupInput | 253 | value={newBranchName} |
| 282 | className="[&_input]:font-sans" | 254 | onChange={(event) => setNewBranchName(event.target.value)} |
| 283 | size="sm" | 255 | onKeyDown={(event) => { |
| 284 | placeholder="branch-name" | 256 | if (event.key === "Escape") { |
| 285 | type="text" | 257 | event.stopPropagation(); |
| 286 | value={newBranchName} | 258 | setIsCreatingBranch(false); |
| 287 | onChange={(event) => setNewBranchName(event.target.value)} | 259 | setNewBranchName(""); |
| 288 | onKeyDown={(event) => { | 260 | } |
| 289 | if (event.key === "Escape") { | 261 | }} |
| 290 | event.stopPropagation(); | 262 | autoFocus |
| 291 | setIsCreatingBranch(false); | 263 | /> |
| 292 | setNewBranchName(""); | 264 | <InputGroupAddon align="inline-end"> |
| 293 | } | 265 | <Button |
| 294 | }} | 266 | size="xs" |
| 295 | autoFocus | 267 | variant="secondary" |
| 296 | /> | 268 | type="submit" |
| 297 | <InputGroupAddon align="inline-end"> | 269 | disabled={!newBranchName.trim() || createBranchMutation.isPending} |
| 298 | <Button | 270 | > |
| 299 | size="xs" | 271 | Create |
| 300 | variant="secondary" | 272 | </Button> |
| 301 | type="submit" | 273 | </InputGroupAddon> |
| 302 | disabled={!newBranchName.trim() || createBranchMutation.isPending} | 274 | </InputGroup> |
| 303 | > | 275 | </form> |
| 304 | Create | 276 | ) : ( |
| 305 | </Button> | 277 | <Button |
| 306 | </InputGroupAddon> | 278 | size="sm" |
| 307 | </InputGroup> | 279 | className="w-full justify-start h-9 rounded-t-none rounded-b-lg before:rounded-t-none" |
| 308 | </form> | 280 | variant="outline" |
| 309 | ) : ( | 281 | onClick={() => setIsCreatingBranch(true)} |
| 310 | <Button | 282 | > |
| 311 | size="sm" | 283 | <PlusIcon /> |
| 312 | className="w-full justify-start h-9 rounded-t-none rounded-b-lg before:rounded-t-none" | 284 | New branch |
| 313 | variant="outline" | 285 | </Button> |
| 314 | onClick={() => setIsCreatingBranch(true)} | 286 | )} |
| 315 | > | 287 | </> |
| 316 | <PlusIcon /> | 288 | )} |
| 317 | New branch | 289 | </ComboboxPopup> |
| 318 | </Button> | 290 | </Combobox> |
| 319 | )} | ||
| 320 | </> | ||
| 321 | )} | ||
| 322 | </ComboboxPopup> | ||
| 323 | </Combobox> | ||
| 324 | )} | ||
| 325 | </div> | 291 | </div> |
| 326 | ); | 292 | ); |
| 327 | } | 293 | } |
apps/web/src/components/ChatView.tsx+8-3| ... | @@ -19,6 +19,8 @@ import { | ... | @@ -19,6 +19,8 @@ import { |
| 19 | useRef, | 19 | useRef, |
| 20 | useState, | 20 | useState, |
| 21 | } from "react"; | 21 | } from "react"; |
| 22 | import { useQuery } from "@tanstack/react-query"; | ||
| 23 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; | ||
| 22 | 24 | ||
| 23 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | 25 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 24 | import { isElectron } from "../env"; | 26 | import { isElectron } from "../env"; |
| ... | @@ -244,6 +246,9 @@ export default function ChatView() { | ... | @@ -244,6 +246,9 @@ export default function ChatView() { |
| 244 | sandboxMode: "workspace-write", | 246 | sandboxMode: "workspace-write", |
| 245 | } as const); | 247 | } as const); |
| 246 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; | 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 | const envLocked = Boolean( | 253 | const envLocked = Boolean( |
| 249 | activeThread && | 254 | activeThread && |
| ... | @@ -1123,7 +1128,7 @@ export default function ChatView() { | ... | @@ -1123,7 +1128,7 @@ export default function ChatView() { |
| 1123 | </div> | 1128 | </div> |
| 1124 | 1129 | ||
| 1125 | {/* Input bar */} | 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 | <form onSubmit={onSend} className="mx-auto max-w-3xl"> | 1132 | <form onSubmit={onSend} className="mx-auto max-w-3xl"> |
| 1128 | <div | 1133 | <div |
| 1129 | className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${ | 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,7 +1304,7 @@ export default function ChatView() { |
| 1299 | </form> | 1304 | </form> |
| 1300 | </div> | 1305 | </div> |
| 1301 | 1306 | ||
| 1302 | <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} /> | 1307 | {isGitRepo && <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />} |
| 1303 | 1308 | ||
| 1304 | {activeThread.terminalOpen && api && activeProject && ( | 1309 | {activeThread.terminalOpen && api && activeProject && ( |
| 1305 | <ThreadTerminalDrawer | 1310 | <ThreadTerminalDrawer |
| ... | @@ -1436,8 +1441,8 @@ function OpenInPicker() { | ... | @@ -1436,8 +1441,8 @@ function OpenInPicker() { |
| 1436 | 1441 | ||
| 1437 | const api = useNativeApi(); | 1442 | const api = useNativeApi(); |
| 1438 | const { state } = useStore(); | 1443 | const { state } = useStore(); |
| 1439 | const activeProject = state.projects.find((p) => p.id === state.activeThreadId); | ||
| 1440 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | 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 | const openInEditor = useCallback( | 1447 | const openInEditor = useCallback( |
| 1443 | (editorId: EditorId | null) => { | 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,6 +10,11 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { |
| 10 | return { | 10 | return { |
| 11 | branch: "feature/test", | 11 | branch: "feature/test", |
| 12 | hasWorkingTreeChanges: false, | 12 | hasWorkingTreeChanges: false, |
| 13 | workingTree: { | ||
| 14 | files: [], | ||
| 15 | insertions: 0, | ||
| 16 | deletions: 0, | ||
| 17 | }, | ||
| 13 | hasUpstream: true, | 18 | hasUpstream: true, |
| 14 | aheadCount: 0, | 19 | aheadCount: 0, |
| 15 | behindCount: 0, | 20 | behindCount: 0, |
| ... | @@ -81,7 +86,7 @@ describe("when: actions are busy", () => { | ... | @@ -81,7 +86,7 @@ describe("when: actions are busy", () => { |
| 81 | const quick = resolveQuickAction(status(), true); | 86 | const quick = resolveQuickAction(status(), true); |
| 82 | assert.deepInclude(quick, { | 87 | assert.deepInclude(quick, { |
| 83 | kind: "show_hint", | 88 | kind: "show_hint", |
| 84 | label: "Running...", | 89 | label: "Commit", |
| 85 | disabled: true, | 90 | disabled: true, |
| 86 | hint: "Git action in progress.", | 91 | hint: "Git action in progress.", |
| 87 | }); | 92 | }); |
| ... | @@ -123,7 +128,7 @@ describe("when: git status is unavailable", () => { | ... | @@ -123,7 +128,7 @@ describe("when: git status is unavailable", () => { |
| 123 | const quick = resolveQuickAction(null, false); | 128 | const quick = resolveQuickAction(null, false); |
| 124 | assert.deepInclude(quick, { | 129 | assert.deepInclude(quick, { |
| 125 | kind: "show_hint", | 130 | kind: "show_hint", |
| 126 | label: "Git actions", | 131 | label: "Commit", |
| 127 | disabled: true, | 132 | disabled: true, |
| 128 | hint: "Git status is unavailable.", | 133 | hint: "Git status is unavailable.", |
| 129 | }); | 134 | }); |
| ... | @@ -314,9 +319,34 @@ describe("when: branch is behind upstream", () => { | ... | @@ -314,9 +319,34 @@ describe("when: branch is behind upstream", () => { |
| 314 | }); | 319 | }); |
| 315 | 320 | ||
| 316 | describe("when: working tree has local changes", () => { | 321 | describe("when: working tree has local changes", () => { |
| 317 | it("resolveQuickAction returns commit", () => { | 322 | it("resolveQuickAction returns commit, push, and create PR", () => { |
| 318 | const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false); | 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 | it("buildMenuItems enables commit and disables push and PR", () => { | 352 | it("buildMenuItems enables commit and disables push and PR", () => { |
| ... | @@ -350,10 +380,36 @@ describe("when: working tree has local changes", () => { | ... | @@ -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 | describe("when: working tree has local changes and branch is behind upstream", () => { | 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 | const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true, behindCount: 1 }), false); | 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 | it("buildMenuItems enables commit and keeps push and PR disabled", () => { | 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,7 +449,7 @@ describe("when: HEAD is detached and there are no local changes", () => { |
| 393 | status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }), | 449 | status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }), |
| 394 | false, | 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 | it("buildMenuItems keeps commit, push, and PR disabled", () => { | 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,17 +484,16 @@ describe("when: HEAD is detached and there are no local changes", () => { |
| 428 | }); | 484 | }); |
| 429 | 485 | ||
| 430 | describe("when: branch has no upstream configured", () => { | 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 | const quick = resolveQuickAction(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false); | 488 | const quick = resolveQuickAction(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false); |
| 433 | assert.deepInclude(quick, { | 489 | assert.deepInclude(quick, { |
| 434 | kind: "run_action", | 490 | kind: "show_hint", |
| 435 | action: "commit_push_pr", | 491 | label: "Commit", |
| 436 | label: "Push & create PR", | 492 | disabled: true, |
| 437 | disabled: false, | ||
| 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 | const quick = resolveQuickAction( | 497 | const quick = resolveQuickAction( |
| 443 | status({ | 498 | status({ |
| 444 | hasUpstream: false, | 499 | hasUpstream: false, |
| ... | @@ -454,9 +509,9 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -454,9 +509,9 @@ describe("when: branch has no upstream configured", () => { |
| 454 | false, | 509 | false, |
| 455 | ); | 510 | ); |
| 456 | assert.deepInclude(quick, { | 511 | assert.deepInclude(quick, { |
| 457 | kind: "open_pr", | 512 | kind: "show_hint", |
| 458 | label: "Open PR", | 513 | label: "Commit", |
| 459 | disabled: false, | 514 | disabled: true, |
| 460 | }); | 515 | }); |
| 461 | }); | 516 | }); |
| 462 | 517 | ||
| ... | @@ -483,7 +538,7 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -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 | const items = buildMenuItems(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false); | 542 | const items = buildMenuItems(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false); |
| 488 | assert.deepEqual(items, [ | 543 | assert.deepEqual(items, [ |
| 489 | { | 544 | { |
| ... | @@ -497,7 +552,7 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -497,7 +552,7 @@ describe("when: branch has no upstream configured", () => { |
| 497 | { | 552 | { |
| 498 | id: "push", | 553 | id: "push", |
| 499 | label: "Push", | 554 | label: "Push", |
| 500 | disabled: false, | 555 | disabled: true, |
| 501 | icon: "push", | 556 | icon: "push", |
| 502 | kind: "open_dialog", | 557 | kind: "open_dialog", |
| 503 | dialogAction: "push", | 558 | dialogAction: "push", |
apps/web/src/components/GitActionsControl.logic.ts+34-36| ... | @@ -61,7 +61,7 @@ export function buildMenuItems( | ... | @@ -61,7 +61,7 @@ export function buildMenuItems( |
| 61 | hasBranch && | 61 | hasBranch && |
| 62 | !hasChanges && | 62 | !hasChanges && |
| 63 | !isBehind && | 63 | !isBehind && |
| 64 | (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream); | 64 | gitStatus.aheadCount > 0; |
| 65 | const canCreatePr = | 65 | const canCreatePr = |
| 66 | !isBusy && | 66 | !isBusy && |
| 67 | hasBranch && | 67 | hasBranch && |
| ... | @@ -111,14 +111,15 @@ export function buildMenuItems( | ... | @@ -111,14 +111,15 @@ export function buildMenuItems( |
| 111 | export function resolveQuickAction( | 111 | export function resolveQuickAction( |
| 112 | gitStatus: GitStatusResult | null, | 112 | gitStatus: GitStatusResult | null, |
| 113 | isBusy: boolean, | 113 | isBusy: boolean, |
| 114 | isDefaultBranch = false, | ||
| 114 | ): GitQuickAction { | 115 | ): GitQuickAction { |
| 115 | if (isBusy) { | 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 | if (!gitStatus) { | 120 | if (!gitStatus) { |
| 120 | return { | 121 | return { |
| 121 | label: "Git actions", | 122 | label: "Commit", |
| 122 | disabled: true, | 123 | disabled: true, |
| 123 | kind: "show_hint", | 124 | kind: "show_hint", |
| 124 | hint: "Git status is unavailable.", | 125 | hint: "Git status is unavailable.", |
| ... | @@ -128,14 +129,31 @@ export function resolveQuickAction( | ... | @@ -128,14 +129,31 @@ export function resolveQuickAction( |
| 128 | const hasBranch = gitStatus.branch !== null; | 129 | const hasBranch = gitStatus.branch !== null; |
| 129 | const hasChanges = gitStatus.hasWorkingTreeChanges; | 130 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 130 | const hasOpenPr = gitStatus.openPr !== null; | 131 | const hasOpenPr = gitStatus.openPr !== null; |
| 132 | const isAhead = gitStatus.aheadCount > 0; | ||
| 131 | const isBehind = gitStatus.behindCount > 0; | 133 | const isBehind = gitStatus.behindCount > 0; |
| 132 | const canPush = hasBranch && !hasChanges && !isBehind; | ||
| 133 | 134 | ||
| 134 | if (!hasChanges && hasOpenPr && gitStatus.aheadCount === 0 && !isBehind) { | 135 | if (!hasBranch) { |
| 135 | return { label: "Open PR", disabled: false, kind: "open_pr" }; | 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 | return { | 157 | return { |
| 140 | label: "Pull", | 158 | label: "Pull", |
| 141 | disabled: false, | 159 | disabled: false, |
| ... | @@ -143,8 +161,8 @@ export function resolveQuickAction( | ... | @@ -143,8 +161,8 @@ export function resolveQuickAction( |
| 143 | }; | 161 | }; |
| 144 | } | 162 | } |
| 145 | 163 | ||
| 146 | if (!hasChanges && canPush && (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream)) { | 164 | if (isAhead) { |
| 147 | if (hasOpenPr) { | 165 | if (hasOpenPr || isDefaultBranch) { |
| 148 | return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; | 166 | return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; |
| 149 | } | 167 | } |
| 150 | return { | 168 | return { |
| ... | @@ -155,36 +173,16 @@ export function resolveQuickAction( | ... | @@ -155,36 +173,16 @@ export function resolveQuickAction( |
| 155 | }; | 173 | }; |
| 156 | } | 174 | } |
| 157 | 175 | ||
| 158 | if (hasChanges) { | 176 | if (hasOpenPr && gitStatus.hasUpstream) { |
| 159 | return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; | ||
| 160 | } | ||
| 161 | |||
| 162 | if (hasOpenPr) { | ||
| 163 | return { label: "Open PR", disabled: false, kind: "open_pr" }; | 177 | return { label: "Open PR", disabled: false, kind: "open_pr" }; |
| 164 | } | 178 | } |
| 165 | 179 | ||
| 166 | if (hasBranch && gitStatus.hasUpstream && !isBehind) { | 180 | return { |
| 167 | if (gitStatus.aheadCount > 0) { | 181 | label: "Commit", |
| 168 | return { label: "Create PR", disabled: false, kind: "run_action", action: "commit_push_pr" }; | 182 | disabled: true, |
| 169 | } | 183 | kind: "show_hint", |
| 170 | return { | 184 | hint: "Branch is up to date. Nothing to commit, push, pull, or open as a PR.", |
| 171 | label: "Commit", | 185 | }; |
| 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." }; | ||
| 188 | } | 186 | } |
| 189 | 187 | ||
| 190 | export function requiresDefaultBranchConfirmation( | 188 | export function requiresDefaultBranchConfirmation( |
apps/web/src/components/GitActionsControl.tsx+386-138| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import { | 1 | import { |
| 2 | type GitStatusResult, | ||
| 2 | type GitStackedAction, | 3 | type GitStackedAction, |
| 3 | type NativeApi, | 4 | type NativeApi, |
| 4 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 5 | import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 6 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 6 | import { useCallback, useMemo, useState } from "react"; | 7 | import { useCallback, useMemo, useState } from "react"; |
| 7 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; | 8 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| 8 | import { GitHubIcon } from "./Icons"; | 9 | import { GitHubIcon } from "./Icons"; |
| ... | @@ -16,6 +17,7 @@ import { | ... | @@ -16,6 +17,7 @@ import { |
| 16 | requiresDefaultBranchConfirmation, | 17 | requiresDefaultBranchConfirmation, |
| 17 | resolveQuickAction, | 18 | resolveQuickAction, |
| 18 | } from "./GitActionsControl.logic"; | 19 | } from "./GitActionsControl.logic"; |
| 20 | import { Badge } from "~/components/ui/badge"; | ||
| 19 | import { Button } from "~/components/ui/button"; | 21 | import { Button } from "~/components/ui/button"; |
| 20 | import { | 22 | import { |
| 21 | Dialog, | 23 | Dialog, |
| ... | @@ -28,14 +30,97 @@ import { | ... | @@ -28,14 +30,97 @@ import { |
| 28 | } from "~/components/ui/dialog"; | 30 | } from "~/components/ui/dialog"; |
| 29 | import { Group, GroupSeparator } from "~/components/ui/group"; | 31 | import { Group, GroupSeparator } from "~/components/ui/group"; |
| 30 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; | 32 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| 33 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | ||
| 31 | import { Textarea } from "~/components/ui/textarea"; | 34 | import { Textarea } from "~/components/ui/textarea"; |
| 32 | import { toastManager } from "~/components/ui/toast"; | 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 | interface GitActionsControlProps { | 46 | interface GitActionsControlProps { |
| 35 | api: NativeApi | undefined; | 47 | api: NativeApi | undefined; |
| 36 | gitCwd: string | null; | 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 | function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { | 124 | function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { |
| 40 | if (icon === "commit") return <GitCommitIcon />; | 125 | if (icon === "commit") return <GitCommitIcon />; |
| 41 | if (icon === "push") return <CloudUploadIcon />; | 126 | if (icon === "push") return <CloudUploadIcon />; |
| ... | @@ -54,21 +139,20 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { | ... | @@ -54,21 +139,20 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 54 | return <InfoIcon className="size-4" />; | 139 | return <InfoIcon className="size-4" />; |
| 55 | } | 140 | } |
| 56 | 141 | ||
| 57 | function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] { | 142 | function buildGitActionProgressStages(input: { |
| 58 | return ["git", "status", gitCwd]; | 143 | action: GitStackedAction; |
| 59 | } | 144 | hasCustomCommitMessage: boolean; |
| 60 | 145 | }): string[] { | |
| 61 | function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null) { | 146 | const commitStages = input.hasCustomCommitMessage |
| 62 | return queryOptions({ | 147 | ? ["Committing..."] |
| 63 | queryKey: getGitStatusQueryKey(gitCwd), | 148 | : ["Generating commit message...", "Committing..."]; |
| 64 | queryFn: async () => { | 149 | if (input.action === "commit") { |
| 65 | if (!api || !gitCwd) { | 150 | return commitStages; |
| 66 | throw new Error("Git status is unavailable."); | 151 | } |
| 67 | } | 152 | if (input.action === "commit_push") { |
| 68 | return api.git.status({ cwd: gitCwd }); | 153 | return [...commitStages, "Pushing..."]; |
| 69 | }, | 154 | } |
| 70 | enabled: !!api && !!gitCwd, | 155 | return [...commitStages, "Pushing...", "Creating PR..."]; |
| 71 | }); | ||
| 72 | } | 156 | } |
| 73 | 157 | ||
| 74 | export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { | 158 | export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { |
| ... | @@ -80,41 +164,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -80,41 +164,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 80 | gitStatusQueryOptions(api, gitCwd), | 164 | gitStatusQueryOptions(api, gitCwd), |
| 81 | ); | 165 | ); |
| 82 | 166 | ||
| 83 | const { data: branchList = null } = useQuery({ | 167 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd)); |
| 84 | queryKey: ["git", "branches", gitCwd], | 168 | // Default to true while loading so we don't flash init controls. |
| 85 | queryFn: async () => { | 169 | const isRepo = branchList?.isRepo ?? true; |
| 86 | if (!api || !gitCwd) throw new Error("Git branches are unavailable."); | ||
| 87 | return api.git.listBranches({ cwd: gitCwd }); | ||
| 88 | }, | ||
| 89 | enabled: !!api && !!gitCwd, | ||
| 90 | }); | ||
| 91 | 170 | ||
| 92 | const runImmediateGitActionMutation = useMutation({ | 171 | const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient })); |
| 93 | mutationFn: async ({ | 172 | |
| 94 | action, | 173 | const runImmediateGitActionMutation = useMutation( |
| 95 | commitMessage, | 174 | gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }), |
| 96 | }: { | 175 | ); |
| 97 | action: GitStackedAction; | 176 | const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient })); |
| 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 | }); | ||
| 118 | 177 | ||
| 119 | const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; | 178 | const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; |
| 120 | const isDefaultBranch = useMemo(() => { | 179 | const isDefaultBranch = useMemo(() => { |
| ... | @@ -129,13 +188,20 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -129,13 +188,20 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 129 | [gitStatus, isGitActionRunning], | 188 | [gitStatus, isGitActionRunning], |
| 130 | ); | 189 | ); |
| 131 | const quickAction = useMemo( | 190 | const quickAction = useMemo( |
| 132 | () => resolveQuickAction(gitStatus, isGitActionRunning), | 191 | () => resolveQuickAction(gitStatus, isGitActionRunning, isDefaultBranch), |
| 133 | [gitStatus, isGitActionRunning], | 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 | const refreshGitStatus = useCallback(async () => { | 202 | const refreshGitStatus = useCallback(async () => { |
| 137 | if (!api || !gitCwd) return; | 203 | if (!api || !gitCwd) return; |
| 138 | await queryClient.invalidateQueries({ queryKey: ["git"] }); | 204 | await invalidateGitQueries(queryClient); |
| 139 | }, [api, gitCwd, queryClient]); | 205 | }, [api, gitCwd, queryClient]); |
| 140 | 206 | ||
| 141 | const maybeConfirmPushToDefaultBranch = useCallback( | 207 | const maybeConfirmPushToDefaultBranch = useCallback( |
| ... | @@ -184,31 +250,60 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -184,31 +250,60 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 184 | const confirmed = await maybeConfirmPushToDefaultBranch(action); | 250 | const confirmed = await maybeConfirmPushToDefaultBranch(action); |
| 185 | if (!confirmed) return; | 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 | const promise = runImmediateGitActionMutation.mutateAsync({ | 277 | const promise = runImmediateGitActionMutation.mutateAsync({ |
| 188 | action, | 278 | action, |
| 189 | ...(commitMessage ? { commitMessage } : {}), | 279 | ...(commitMessage ? { commitMessage } : {}), |
| 190 | }); | 280 | }); |
| 191 | toastManager.promise(promise, { | 281 | |
| 192 | loading: { | 282 | try { |
| 193 | title: | 283 | const result = await promise; |
| 194 | action === "commit" | 284 | stopProgressUpdates(); |
| 195 | ? "Committing..." | 285 | |
| 196 | : action === "commit_push" | 286 | const prUrl = result.pr.url ?? gitStatus?.openPr?.url; |
| 197 | ? "Pushing..." | 287 | const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; |
| 198 | : "Creating PR...", | 288 | const shouldOfferOpenPrCta = |
| 199 | }, | 289 | (action === "commit_push" || action === "commit_push_pr") && !!prUrl; |
| 200 | success: (result) => { | 290 | const shouldOfferCreatePrCta = |
| 201 | const prUrl = result.pr.url ?? gitStatus?.openPr?.url; | 291 | action === "commit_push" && !prUrl && result.push.status === "pushed"; |
| 202 | const shouldOfferCreatePrCta = | 292 | |
| 203 | action === "commit_push" && | 293 | toastManager.update(progressToastId, { |
| 204 | !prUrl && | 294 | type: "success", |
| 205 | result.push.status === "pushed"; | 295 | title: "Done", |
| 206 | return { | 296 | description: describeGitResult(result), |
| 207 | title: "Done", | 297 | timeout: 10_000, |
| 208 | description: describeGitResult(result), | 298 | ...(shouldOfferPushCta |
| 209 | ...(prUrl | 299 | ? { |
| 300 | actionProps: { | ||
| 301 | children: "Push", | ||
| 302 | onClick: () => setActiveDialogAction("push"), | ||
| 303 | }, | ||
| 304 | } | ||
| 305 | : shouldOfferOpenPrCta | ||
| 210 | ? { | 306 | ? { |
| 211 | timeout: 10_000, | ||
| 212 | actionProps: { | 307 | actionProps: { |
| 213 | children: "Open PR", | 308 | children: "Open PR", |
| 214 | onClick: () => void api?.shell.openExternal(prUrl), | 309 | onClick: () => void api?.shell.openExternal(prUrl), |
| ... | @@ -216,21 +311,21 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -216,21 +311,21 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 216 | } | 311 | } |
| 217 | : shouldOfferCreatePrCta | 312 | : shouldOfferCreatePrCta |
| 218 | ? { | 313 | ? { |
| 219 | timeout: 10_000, | ||
| 220 | actionProps: { | 314 | actionProps: { |
| 221 | children: "Create PR", | 315 | children: "Create PR", |
| 222 | onClick: () => setActiveDialogAction("create_pr"), | 316 | onClick: () => setActiveDialogAction("create_pr"), |
| 223 | }, | 317 | }, |
| 224 | } | 318 | } |
| 225 | : {}), | 319 | : {}), |
| 226 | }; | 320 | }); |
| 227 | }, | 321 | } catch (err) { |
| 228 | error: (err) => ({ | 322 | stopProgressUpdates(); |
| 323 | toastManager.update(progressToastId, { | ||
| 324 | type: "error", | ||
| 229 | title: "Action failed", | 325 | title: "Action failed", |
| 230 | description: err instanceof Error ? err.message : "An error occurred.", | 326 | description: err instanceof Error ? err.message : "An error occurred.", |
| 231 | }), | 327 | }); |
| 232 | }); | 328 | } |
| 233 | void promise.catch(() => undefined); | ||
| 234 | }, | 329 | }, |
| 235 | [ | 330 | [ |
| 236 | api, | 331 | api, |
| ... | @@ -309,6 +404,27 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -309,6 +404,27 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 309 | }); | 404 | }); |
| 310 | }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]); | 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 | const dialogTitle = | 428 | const dialogTitle = |
| 313 | activeDialogAction === "commit" | 429 | activeDialogAction === "commit" |
| 314 | ? "Commit changes" | 430 | ? "Commit changes" |
| ... | @@ -321,65 +437,144 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -321,65 +437,144 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 321 | : activeDialogAction === "push" | 437 | : activeDialogAction === "push" |
| 322 | ? "Push this branch now. If this is the default branch, you'll be asked to confirm." | 438 | ? "Push this branch now. If this is the default branch, you'll be asked to confirm." |
| 323 | : "Create a pull request using generated title/body content."; | 439 | : "Create a pull request using generated title/body content."; |
| 440 | const showCompactStatusCard = | ||
| 441 | !!gitStatus && gitStatus.hasWorkingTreeChanges && !gitStatus.hasUpstream; | ||
| 324 | 442 | ||
| 325 | if (!gitCwd) return null; | 443 | if (!gitCwd) return null; |
| 326 | 444 | ||
| 327 | return ( | 445 | return ( |
| 328 | <> | 446 | <> |
| 329 | <Group aria-label="Git actions"> | 447 | {!isRepo ? ( |
| 330 | <Button | 448 | <Button |
| 331 | variant="outline" | 449 | variant="outline" |
| 332 | size="xs" | 450 | size="xs" |
| 333 | disabled={isGitActionRunning || quickAction.disabled} | 451 | disabled={initMutation.isPending} |
| 334 | onClick={runQuickAction} | 452 | onClick={() => initMutation.mutate()} |
| 335 | > | 453 | > |
| 336 | <GitQuickActionIcon quickAction={quickAction} /> | 454 | {initMutation.isPending ? "Initializing..." : "Initialize Git"} |
| 337 | {quickAction.label} | ||
| 338 | </Button> | 455 | </Button> |
| 339 | <GroupSeparator /> | 456 | ) : ( |
| 340 | <Menu | 457 | <Group aria-label="Git actions"> |
| 341 | onOpenChange={(open) => { | 458 | {quickActionDisabledReason ? ( |
| 342 | if (open) void refreshGitStatus(); | 459 | <Popover open={isQuickActionPopoverOpen} onOpenChange={setIsQuickActionPopoverOpen}> |
| 343 | }} | 460 | <PopoverTrigger |
| 344 | > | 461 | render={ |
| 345 | <MenuTrigger | 462 | <span |
| 346 | render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />} | 463 | className="inline-flex cursor-not-allowed" |
| 347 | disabled={isGitActionRunning} | 464 | onBlur={() => setIsQuickActionPopoverOpen(false)} |
| 348 | > | 465 | onFocus={() => setIsQuickActionPopoverOpen(true)} |
| 349 | <ChevronDownIcon aria-hidden="true" className="size-4" /> | 466 | onMouseEnter={() => setIsQuickActionPopoverOpen(true)} |
| 350 | </MenuTrigger> | 467 | onMouseLeave={() => setIsQuickActionPopoverOpen(false)} |
| 351 | <MenuPopup align="end" sideOffset={4}> | 468 | tabIndex={0} |
| 352 | {gitActionMenuItems.map((item) => ( | 469 | /> |
| 353 | <MenuItem | 470 | } |
| 354 | key={`${item.id}-${item.label}`} | ||
| 355 | disabled={item.disabled} | ||
| 356 | onClick={() => { | ||
| 357 | openDialogForMenuItem(item); | ||
| 358 | }} | ||
| 359 | > | 471 | > |
| 360 | <GitActionItemIcon icon={item.icon} /> | 472 | <Button |
| 361 | {item.label} | 473 | variant="outline" |
| 362 | </MenuItem> | 474 | size="xs" |
| 363 | ))} | 475 | disabled={isGitActionRunning || quickAction.disabled} |
| 364 | {gitStatus?.branch === null && ( | 476 | className="cursor-not-allowed disabled:cursor-not-allowed" |
| 365 | <p className="px-2 py-1.5 text-xs text-warning"> | 477 | onClick={runQuickAction} |
| 366 | Detached HEAD: create and checkout a branch to enable push and PR actions. | 478 | > |
| 367 | </p> | 479 | <GitQuickActionIcon quickAction={quickAction} /> |
| 368 | )} | 480 | {quickAction.label} |
| 369 | {gitStatus && | 481 | </Button> |
| 370 | gitStatus.branch !== null && | 482 | </PopoverTrigger> |
| 371 | !gitStatus.hasWorkingTreeChanges && | 483 | <PopoverPopup tooltipStyle side="bottom" align="start"> |
| 372 | gitStatus.behindCount > 0 && ( | 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 | <p className="px-2 py-1.5 text-xs text-warning"> | 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 | </p> | 561 | </p> |
| 376 | )} | 562 | )} |
| 377 | {gitStatusError && ( | 563 | {gitStatus && |
| 378 | <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p> | 564 | gitStatus.branch !== null && |
| 379 | )} | 565 | !gitStatus.hasWorkingTreeChanges && |
| 380 | </MenuPopup> | 566 | gitStatus.behindCount > 0 && ( |
| 381 | </Menu> | 567 | <p className="px-2 py-1.5 text-xs text-warning"> |
| 382 | </Group> | 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 | <Dialog | 579 | <Dialog |
| 385 | open={activeDialogAction !== null} | 580 | open={activeDialogAction !== null} |
| ... | @@ -396,19 +591,72 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -396,19 +591,72 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 396 | <DialogDescription>{dialogDescription}</DialogDescription> | 591 | <DialogDescription>{dialogDescription}</DialogDescription> |
| 397 | </DialogHeader> | 592 | </DialogHeader> |
| 398 | <DialogPanel className="space-y-4"> | 593 | <DialogPanel className="space-y-4"> |
| 399 | <div className="rounded-lg border border-input bg-muted/40 p-3 text-xs"> | 594 | <div className="space-y-3 rounded-lg border border-input bg-muted/40 p-3 text-xs"> |
| 400 | <p> | 595 | <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1"> |
| 401 | <span className="font-medium">Branch:</span>{" "} | 596 | <span className="text-muted-foreground">Branch</span> |
| 402 | {gitStatus?.branch ?? "(detached HEAD)"} | 597 | <span className="font-medium">{gitStatus?.branch ?? "(detached HEAD)"}</span> |
| 403 | </p> | 598 | {!showCompactStatusCard && ( |
| 404 | <p> | 599 | <> |
| 405 | <span className="font-medium">Working tree:</span>{" "} | 600 | <span className="text-muted-foreground">Upstream</span> |
| 406 | {gitStatus?.hasWorkingTreeChanges ? "Has changes" : "Clean"} | 601 | <span className="font-medium"> |
| 407 | </p> | 602 | {gitStatus |
| 408 | <p> | 603 | ? formatUpstreamStatus({ |
| 409 | <span className="font-medium">Ahead/behind:</span>{" "} | 604 | hasUpstream: gitStatus.hasUpstream, |
| 410 | {gitStatus ? `${gitStatus.aheadCount}/${gitStatus.behindCount}` : "unknown"} | 605 | aheadCount: gitStatus.aheadCount, |
| 411 | </p> | 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 | </div> | 660 | </div> |
| 413 | {activeDialogAction === "commit" && ( | 661 | {activeDialogAction === "commit" && ( |
| 414 | <div className="space-y-1"> | 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,7 +37,7 @@ interface ToastProviderProps extends Toast.Provider.Props { |
| 37 | 37 | ||
| 38 | function ToastProvider({ | 38 | function ToastProvider({ |
| 39 | children, | 39 | children, |
| 40 | position = "bottom-right", | 40 | position = "top-right", |
| 41 | ...props | 41 | ...props |
| 42 | }: ToastProviderProps) { | 42 | }: ToastProviderProps) { |
| 43 | return ( | 43 | return ( |
| ... | @@ -48,7 +48,7 @@ function ToastProvider({ | ... | @@ -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 | const { toasts } = Toast.useToastManager(); | 52 | const { toasts } = Toast.useToastManager(); |
| 53 | const isTop = position.startsWith("top"); | 53 | const isTop = position.startsWith("top"); |
| 54 | 54 | ||
| ... | @@ -56,9 +56,9 @@ function Toasts({ position = "bottom-right" }: { position: ToastPosition }) { | ... | @@ -56,9 +56,9 @@ function Toasts({ position = "bottom-right" }: { position: ToastPosition }) { |
| 56 | <Toast.Portal data-slot="toast-portal"> | 56 | <Toast.Portal data-slot="toast-portal"> |
| 57 | <Toast.Viewport | 57 | <Toast.Viewport |
| 58 | className={cn( | 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 | // Vertical positioning | 60 | // Vertical positioning |
| 61 | "data-[position*=top]:top-(--toast-inset)", | 61 | "data-[position*=top]:top-[calc(var(--toast-inset)+var(--toast-header-offset))]", |
| 62 | "data-[position*=bottom]:bottom-(--toast-inset)", | 62 | "data-[position*=bottom]:bottom-(--toast-inset)", |
| 63 | // Horizontal positioning | 63 | // Horizontal positioning |
| 64 | "data-[position*=left]:left-(--toast-inset)", | 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,6 +25,14 @@ describe("git contracts", () => { |
| 25 | const parsed = gitStatusResultSchema.parse({ | 25 | const parsed = gitStatusResultSchema.parse({ |
| 26 | branch: "feature/git-actions", | 26 | branch: "feature/git-actions", |
| 27 | hasWorkingTreeChanges: true, | 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 | hasUpstream: false, | 36 | hasUpstream: false, |
| 29 | aheadCount: 0, | 37 | aheadCount: 0, |
| 30 | behindCount: 0, | 38 | behindCount: 0, |
| ... | @@ -32,6 +40,8 @@ describe("git contracts", () => { | ... | @@ -32,6 +40,8 @@ describe("git contracts", () => { |
| 32 | }); | 40 | }); |
| 33 | expect(parsed.branch).toBe("feature/git-actions"); | 41 | expect(parsed.branch).toBe("feature/git-actions"); |
| 34 | expect(parsed.hasWorkingTreeChanges).toBe(true); | 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 | it("accepts supported stacked actions", () => { | 47 | it("accepts supported stacked actions", () => { |
packages/contracts/src/git.ts+11| ... | @@ -75,6 +75,17 @@ export const gitPullInputSchema = z.object({ | ... | @@ -75,6 +75,17 @@ export const gitPullInputSchema = z.object({ |
| 75 | export const gitStatusResultSchema = z.object({ | 75 | export const gitStatusResultSchema = z.object({ |
| 76 | branch: z.string().min(1).nullable(), | 76 | branch: z.string().min(1).nullable(), |
| 77 | hasWorkingTreeChanges: z.boolean(), | 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 | hasUpstream: z.boolean(), | 89 | hasUpstream: z.boolean(), |
| 79 | aheadCount: z.number().int().nonnegative(), | 90 | aheadCount: z.number().int().nonnegative(), |
| 80 | behindCount: z.number().int().nonnegative(), | 91 | behindCount: z.number().int().nonnegative(), |