From 85eb379dd769db2f949a3352acca6537b02e89cf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Feb 2026 22:36:16 -0800 Subject: [PATCH] Improve git status details and streamline quick action flows - 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 behavior --- apps/server/src/git.ts | 87 ++- apps/server/src/gitManager.ts | 1 + apps/server/src/wsServer.test.ts | 10 + apps/web/src/components/BranchToolbar.tsx | 270 ++++----- apps/web/src/components/ChatView.tsx | 11 +- .../GitActionsControl.logic.test.ts | 91 ++- .../src/components/GitActionsControl.logic.ts | 70 ++- apps/web/src/components/GitActionsControl.tsx | 524 +++++++++++++----- apps/web/src/components/ui/toast.tsx | 8 +- apps/web/src/lib/gitReactQuery.ts | 131 +++++ packages/contracts/src/git.test.ts | 10 + packages/contracts/src/git.ts | 11 + 12 files changed, 871 insertions(+), 353 deletions(-) create mode 100644 apps/web/src/lib/gitReactQuery.ts diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index 6ce097cfea334648a12910839aa67924f426965b..eab1805a5c4f6b03de3038d1533bc2a3e62c2152 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -94,6 +94,51 @@ function parseBranchAb(value: string): { ahead: number; behind: number } { }; } +function parseNumstatEntries( + stdout: string, +): Array<{ path: string; insertions: number; deletions: number }> { + const entries: Array<{ path: string; insertions: number; deletions: number }> = []; + for (const line of stdout.split(/\r?\n/g)) { + if (line.trim().length === 0) continue; + const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); + const rawPath = pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); + if (rawPath.length === 0) continue; + const added = Number.parseInt(addedRaw ?? "0", 10); + const deleted = Number.parseInt(deletedRaw ?? "0", 10); + const renameArrowIndex = rawPath.indexOf(" => "); + const normalizedPath = + renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; + entries.push({ + path: normalizedPath.length > 0 ? normalizedPath : rawPath, + insertions: Number.isFinite(added) ? added : 0, + deletions: Number.isFinite(deleted) ? deleted : 0, + }); + } + return entries; +} + +function parsePorcelainPath(line: string): string | null { + if (line.startsWith("? ") || line.startsWith("! ")) { + const simple = line.slice(2).trim(); + return simple.length > 0 ? simple : null; + } + + if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { + return null; + } + + const tabIndex = line.indexOf("\t"); + if (tabIndex >= 0) { + const fromTab = line.slice(tabIndex + 1); + const [path] = fromTab.split("\t"); + return path?.trim().length ? path.trim() : null; + } + + const parts = line.trim().split(/\s+/g); + const path = parts.at(-1) ?? ""; + return path.length > 0 ? path : null; +} + function commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`; } @@ -176,6 +221,7 @@ export class GitCoreService { return gitStatusResultSchema.parse({ branch: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, @@ -184,15 +230,20 @@ export class GitCoreService { } async statusDetails(cwd: string): Promise { - const stdout = await this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]); + const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ + this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), + this.gitStdout(cwd, ["diff", "--numstat"]), + this.gitStdout(cwd, ["diff", "--cached", "--numstat"]), + ]); let branch: string | null = null; let upstreamRef: string | null = null; let aheadCount = 0; let behindCount = 0; let hasWorkingTreeChanges = false; + const changedFilesWithoutNumstat = new Set(); - for (const line of stdout.split(/\r?\n/g)) { + for (const line of statusStdout.split(/\r?\n/g)) { if (line.startsWith("# branch.head ")) { const value = line.slice("# branch.head ".length).trim(); branch = value.startsWith("(") ? null : value; @@ -212,13 +263,45 @@ export class GitCoreService { } if (line.trim().length > 0 && !line.startsWith("#")) { hasWorkingTreeChanges = true; + const pathValue = parsePorcelainPath(line); + if (pathValue) changedFilesWithoutNumstat.add(pathValue); } } + const stagedEntries = parseNumstatEntries(stagedNumstatStdout); + const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); + const fileStatMap = new Map(); + for (const entry of [...stagedEntries, ...unstagedEntries]) { + const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; + existing.insertions += entry.insertions; + existing.deletions += entry.deletions; + fileStatMap.set(entry.path, existing); + } + + let insertions = 0; + let deletions = 0; + const files = Array.from(fileStatMap.entries()) + .map(([path, stat]) => { + insertions += stat.insertions; + deletions += stat.deletions; + return { path, insertions: stat.insertions, deletions: stat.deletions }; + }) + .toSorted((a, b) => a.path.localeCompare(b.path)); + + for (const filePath of changedFilesWithoutNumstat) { + if (fileStatMap.has(filePath)) continue; + files.push({ path: filePath, insertions: 0, deletions: 0 }); + } + files.sort((a, b) => a.path.localeCompare(b.path)); return { branch, upstreamRef, hasWorkingTreeChanges, + workingTree: { + files, + insertions, + deletions, + }, hasUpstream: upstreamRef !== null, aheadCount, behindCount, diff --git a/apps/server/src/gitManager.ts b/apps/server/src/gitManager.ts index 4bc511761ef441c6ab79b83b81612b8ea066004a..91d8ea21239f001d0d03a7974081ca9341d032bd 100644 --- a/apps/server/src/gitManager.ts +++ b/apps/server/src/gitManager.ts @@ -218,6 +218,7 @@ export class GitManager { return { branch: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index d86da3b184d0e21403590960075f1083aca33664..1f84d880a40ef454f3d6ebc4a0faa4a8aa026699 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -557,6 +557,11 @@ describe("WebSocket Server", () => { status: vi.fn().mockResolvedValue({ branch: "feature/test", hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], + insertions: 7, + deletions: 2, + }, hasUpstream: false, aheadCount: 0, behindCount: 0, @@ -581,6 +586,11 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ branch: "feature/test", hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], + insertions: 7, + deletions: 2, + }, hasUpstream: false, aheadCount: 0, behindCount: 0, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index b9b38a4b19ef5c22e3f19093c942e0fb3ec386eb..9e25228f8eeecdc9056a1bdd3450b92ea828c7a9 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -3,6 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { useNativeApi } from "../hooks/useNativeApi"; +import { + gitBranchesQueryOptions, + gitCheckoutMutationOptions, + gitCreateBranchAndCheckoutMutationOptions, +} from "../lib/gitReactQuery"; import { useStore } from "../store"; import { Button } from "./ui/button"; import { @@ -37,28 +42,19 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B const activeThreadId = activeThread?.id; const activeThreadBranch = activeThread?.branch ?? null; const activeWorktreePath = activeThread?.worktreePath ?? null; - const branchCwd = activeWorktreePath ?? activeProject?.cwd; + const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null; // ── Queries ─────────────────────────────────────────────────────────── - const branchesQuery = useQuery({ - queryKey: ["git", "branches", branchCwd], - queryFn: () => api!.git.listBranches({ cwd: branchCwd! }), - enabled: !!api && !!branchCwd, - }); + const branchesQuery = useQuery(gitBranchesQueryOptions(api, branchCwd)); const branches = branchesQuery.data?.branches ?? []; const branchNames = branches.map((branch) => branch.name); const branchByName = new Map(branches.map((branch) => [branch.name, branch])); - // Default to true while loading — showing "Initialize git" during a fetch is wrong, - // and worktrees are inherently git repos. - const isRepo = branchesQuery.data?.isRepo ?? !branchesQuery.isLoading; - // ── Mutations ───────────────────────────────────────────────────────── const checkoutMutation = useMutation({ - mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }), + ...gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), onError: (error) => { setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."); setIsBranchMenuOpen(true); @@ -66,25 +62,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B }); const createBranchMutation = useMutation({ - mutationFn: (branch: string) => - api!.git - .createBranch({ cwd: branchCwd!, branch }) - .then(() => api!.git.checkout({ cwd: branchCwd!, branch })), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }), + ...gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), onError: (error) => setThreadError(error instanceof Error ? error.message : "Failed to create branch."), }); - const initMutation = useMutation({ - mutationFn: () => api!.git.init({ cwd: branchCwd! }), - onSuccess: () => { - setThreadError(null); - queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }); - }, - onError: (error) => - setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo."), - }); - // ── Effects ─────────────────────────────────────────────────────────── // Keep thread branch synced to git current branch for local threads. @@ -194,134 +176,118 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B )} - {!isRepo ? ( - - ) : ( - setIsBranchMenuOpen(open)} - open={isBranchMenuOpen} - value={activeThread.branch} - > - } - className="text-muted-foreground/70 hover:text-foreground/80" - disabled={branchesQuery.isLoading} - > - - {activeThread.branch - ? envMode === "worktree" && !activeWorktreePath - ? `From ${activeThread.branch}` - : activeThread.branch - : "Select branch"} - - - - -
- -
- No branches found. + + {activeThread.branch + ? envMode === "worktree" && !activeWorktreePath + ? `From ${activeThread.branch}` + : activeThread.branch + : "Select branch"} + + + + +
+ +
+ No branches found. - - {(branchName) => { - const branch = branchByName.get(branchName); - if (!branch) return null; + + {(branchName) => { + const branch = branchByName.get(branchName); + if (!branch) return null; - const hasSecondaryWorktree = - branch.worktreePath && branch.worktreePath !== activeProject.cwd; - return ( - selectBranch(branch)} - > -
- {branchName} - {(branch.current || branch.isDefault || hasSecondaryWorktree) && ( - - {branch.current - ? "current" - : hasSecondaryWorktree - ? "worktree" - : "default"} - - )} -
-
- ); - }} -
- {envMode === "local" && ( - <> - {isCreatingBranch ? ( -
{ - event.preventDefault(); - createBranch(); - }} - > - - setNewBranchName(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Escape") { - event.stopPropagation(); - setIsCreatingBranch(false); - setNewBranchName(""); - } - }} - autoFocus - /> - - - - -
- ) : ( - - )} - - )} -
-
- )} + const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProject.cwd; + return ( + selectBranch(branch)} + > +
+ {branchName} + {(branch.current || branch.isDefault || hasSecondaryWorktree) && ( + + {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"} + + )} +
+
+ ); + }} + + {envMode === "local" && ( + <> + {isCreatingBranch ? ( +
{ + event.preventDefault(); + createBranch(); + }} + > + + setNewBranchName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.stopPropagation(); + setIsCreatingBranch(false); + setNewBranchName(""); + } + }} + autoFocus + /> + + + + +
+ ) : ( + + )} + + )} + + ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c1ba0cbd9c9e798f8e6525f9888ee0815fc7f979..9f3f5fb850d21d8e68416a0416ada15d6c0bfdbd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -19,6 +19,8 @@ import { useRef, useState, } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; import { EDITORS, type EditorId } from "@t3tools/contracts"; import { isElectron } from "../env"; @@ -244,6 +246,9 @@ export default function ChatView() { sandboxMode: "workspace-write", } as const); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; + const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); + // Default true while loading to avoid toolbar flicker. + const isGitRepo = branchesQuery.data?.isRepo ?? true; const envLocked = Boolean( activeThread && @@ -1123,7 +1128,7 @@ export default function ChatView() { {/* Input bar */} -
+
- + {isGitRepo && } {activeThread.terminalOpen && api && activeProject && ( p.id === state.activeThreadId); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); + const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); const openInEditor = useCallback( (editorId: EditorId | null) => { diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 4cc8dd9e8b21b61c791041aaa7b67bf40b74557f..0e1583e1bd6db0deefb2ae2e2ceafd25ffd9a68d 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -10,6 +10,11 @@ function status(overrides: Partial = {}): GitStatusResult { return { branch: "feature/test", hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, hasUpstream: true, aheadCount: 0, behindCount: 0, @@ -81,7 +86,7 @@ describe("when: actions are busy", () => { const quick = resolveQuickAction(status(), true); assert.deepInclude(quick, { kind: "show_hint", - label: "Running...", + label: "Commit", disabled: true, hint: "Git action in progress.", }); @@ -123,7 +128,7 @@ describe("when: git status is unavailable", () => { const quick = resolveQuickAction(null, false); assert.deepInclude(quick, { kind: "show_hint", - label: "Git actions", + label: "Commit", disabled: true, hint: "Git status is unavailable.", }); @@ -314,9 +319,34 @@ describe("when: branch is behind upstream", () => { }); describe("when: working tree has local changes", () => { - it("resolveQuickAction returns commit", () => { + it("resolveQuickAction returns commit, push, and create PR", () => { const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false); - assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" }); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push_pr", + label: "Commit, push & create PR", + }); + }); + + it("resolveQuickAction returns commit and push when open PR exists", () => { + const quick = resolveQuickAction( + status({ + hasWorkingTreeChanges: true, + openPr: { + number: 16, + title: "Existing PR", + url: "https://example.com/pr/16", + baseBranch: "main", + headBranch: "feature/test", + }, + }), + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push", + label: "Commit & push", + }); }); it("buildMenuItems enables commit and disables push and PR", () => { @@ -350,10 +380,36 @@ describe("when: working tree has local changes", () => { }); }); +describe("when: on default branch without open PR", () => { + it("resolveQuickAction returns commit and push when local changes exist", () => { + const quick = resolveQuickAction(status({ branch: "main", hasWorkingTreeChanges: true }), false, true); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push", + label: "Commit & push", + disabled: false, + }); + }); + + it("resolveQuickAction returns push when branch is ahead", () => { + const quick = resolveQuickAction(status({ branch: "main", aheadCount: 2, openPr: null }), false, true); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push", + label: "Push", + disabled: false, + }); + }); +}); + describe("when: working tree has local changes and branch is behind upstream", () => { - it("resolveQuickAction still prefers commit", () => { + it("resolveQuickAction still prefers commit, push, and create PR", () => { const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true, behindCount: 1 }), false); - assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" }); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push_pr", + label: "Commit, push & create PR", + }); }); 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", () => { status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }), false, ); - assert.deepInclude(quick, { kind: "show_hint", label: "Detached HEAD", disabled: false }); + assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); }); it("buildMenuItems keeps commit, push, and PR disabled", () => { @@ -428,17 +484,16 @@ describe("when: HEAD is detached and there are no local changes", () => { }); describe("when: branch has no upstream configured", () => { - it("resolveQuickAction runs push and create PR when clean and no open PR", () => { + it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => { const quick = resolveQuickAction(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false); assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push_pr", - label: "Push & create PR", - disabled: false, + kind: "show_hint", + label: "Commit", + disabled: true, }); }); - it("resolveQuickAction opens PR when clean, no upstream, and no local commits ahead", () => { + it("resolveQuickAction remains disabled when clean, no upstream, and no local commits ahead", () => { const quick = resolveQuickAction( status({ hasUpstream: false, @@ -454,9 +509,9 @@ describe("when: branch has no upstream configured", () => { false, ); assert.deepInclude(quick, { - kind: "open_pr", - label: "Open PR", - disabled: false, + kind: "show_hint", + label: "Commit", + disabled: true, }); }); @@ -483,7 +538,7 @@ describe("when: branch has no upstream configured", () => { }); }); - it("buildMenuItems enables push and disables create PR", () => { + it("buildMenuItems disables push and create PR when no commits are ahead", () => { const items = buildMenuItems(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false); assert.deepEqual(items, [ { @@ -497,7 +552,7 @@ describe("when: branch has no upstream configured", () => { { id: "push", label: "Push", - disabled: false, + disabled: true, icon: "push", kind: "open_dialog", dialogAction: "push", diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index fda15debc93b523b691d2581d9f347033cf2bd41..1801ef43bdf71b76ba485f1a6d8a7daca13a62db 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -61,7 +61,7 @@ export function buildMenuItems( hasBranch && !hasChanges && !isBehind && - (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream); + gitStatus.aheadCount > 0; const canCreatePr = !isBusy && hasBranch && @@ -111,14 +111,15 @@ export function buildMenuItems( export function resolveQuickAction( gitStatus: GitStatusResult | null, isBusy: boolean, + isDefaultBranch = false, ): GitQuickAction { if (isBusy) { - return { label: "Running...", disabled: true, kind: "show_hint", hint: "Git action in progress." }; + return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; } if (!gitStatus) { return { - label: "Git actions", + label: "Commit", disabled: true, kind: "show_hint", hint: "Git status is unavailable.", @@ -128,14 +129,31 @@ export function resolveQuickAction( const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.openPr !== null; + const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; - const canPush = hasBranch && !hasChanges && !isBehind; - if (!hasChanges && hasOpenPr && gitStatus.aheadCount === 0 && !isBehind) { - return { label: "Open PR", disabled: false, kind: "open_pr" }; + if (!hasBranch) { + return { + label: "Commit", + disabled: true, + kind: "show_hint", + hint: "Create and checkout a branch before pushing or opening a PR.", + }; } - if (!hasChanges && isBehind) { + if (hasChanges) { + if (hasOpenPr || isDefaultBranch) { + return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; + } + return { + label: "Commit, push & create PR", + disabled: false, + kind: "run_action", + action: "commit_push_pr", + }; + } + + if (isBehind) { return { label: "Pull", disabled: false, @@ -143,8 +161,8 @@ export function resolveQuickAction( }; } - if (!hasChanges && canPush && (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream)) { - if (hasOpenPr) { + if (isAhead) { + if (hasOpenPr || isDefaultBranch) { return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; } return { @@ -155,36 +173,16 @@ export function resolveQuickAction( }; } - if (hasChanges) { - return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; - } - - if (hasOpenPr) { + if (hasOpenPr && gitStatus.hasUpstream) { return { label: "Open PR", disabled: false, kind: "open_pr" }; } - if (hasBranch && gitStatus.hasUpstream && !isBehind) { - if (gitStatus.aheadCount > 0) { - return { label: "Create PR", disabled: false, kind: "run_action", action: "commit_push_pr" }; - } - return { - label: "Commit", - disabled: true, - kind: "show_hint", - hint: "Branch is up to date. Nothing to commit, push, pull, or open as a PR.", - }; - } - - if (!hasBranch) { - return { - label: "Detached HEAD", - disabled: false, - kind: "show_hint", - hint: "Create and checkout a branch before pushing or opening a PR.", - }; - } - - return { label: "Commit", disabled: true, kind: "show_hint", hint: "No git action needed." }; + return { + label: "Commit", + disabled: true, + kind: "show_hint", + hint: "Branch is up to date. Nothing to commit, push, pull, or open as a PR.", + }; } export function requiresDefaultBranchConfirmation( diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f835e616bbe4857f733a49d439722efe34898e28..c855729272e2694657ca0cb5ee3a7d23cf9c29e5 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,8 +1,9 @@ import { + type GitStatusResult, type GitStackedAction, type NativeApi, } from "@t3tools/contracts"; -import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; import { GitHubIcon } from "./Icons"; @@ -16,6 +17,7 @@ import { requiresDefaultBranchConfirmation, resolveQuickAction, } from "./GitActionsControl.logic"; +import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Dialog, @@ -28,14 +30,97 @@ import { } from "~/components/ui/dialog"; import { Group, GroupSeparator } from "~/components/ui/group"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Textarea } from "~/components/ui/textarea"; import { toastManager } from "~/components/ui/toast"; +import { + gitBranchesQueryOptions, + gitInitMutationOptions, + gitPullMutationOptions, + gitRunStackedActionMutationOptions, + gitStatusQueryOptions, + invalidateGitQueries, +} from "~/lib/gitReactQuery"; +import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; interface GitActionsControlProps { api: NativeApi | undefined; gitCwd: string | null; } +function getMenuActionDisabledReason( + item: GitActionMenuItem, + gitStatus: GitStatusResult | null, + isBusy: boolean, +): string | null { + if (!item.disabled) return null; + if (isBusy) return "Git action in progress."; + if (!gitStatus) return "Git status is unavailable."; + + const hasBranch = gitStatus.branch !== null; + const hasChanges = gitStatus.hasWorkingTreeChanges; + const hasOpenPr = gitStatus.openPr !== null; + const isAhead = gitStatus.aheadCount > 0; + const isBehind = gitStatus.behindCount > 0; + + if (item.id === "commit") { + if (!hasChanges) { + return "Worktree is clean. Make changes before committing."; + } + return "Commit is currently unavailable."; + } + + if (item.id === "push") { + if (!hasBranch) { + return "Detached HEAD: checkout a branch before pushing."; + } + if (hasChanges) { + return "Commit or stash local changes before pushing."; + } + if (isBehind) { + return "Branch is behind upstream. Pull/rebase before pushing."; + } + if (!isAhead) { + return "No local commits to push."; + } + return "Push is currently unavailable."; + } + + if (hasOpenPr) { + return "Open PR is currently unavailable."; + } + if (!hasBranch) { + return "Detached HEAD: checkout a branch before creating a PR."; + } + if (hasChanges) { + return "Commit local changes before creating a PR."; + } + if (!isAhead) { + return "No local commits to include in a PR."; + } + if (!gitStatus.hasUpstream) { + return "Set an upstream branch before creating a PR."; + } + if (isBehind) { + return "Branch is behind upstream. Pull/rebase before creating a PR."; + } + return "Create PR is currently unavailable."; +} + +function formatUpstreamStatus(input: { + hasUpstream: boolean; + aheadCount: number; + behindCount: number; +}): string { + if (!input.hasUpstream) return "No upstream configured"; + if (input.aheadCount === 0 && input.behindCount === 0) return "Up to date"; + if (input.aheadCount > 0 && input.behindCount > 0) { + return `Diverged (+${input.aheadCount} / -${input.behindCount})`; + } + if (input.aheadCount > 0) return `Ahead by ${input.aheadCount}`; + return `Behind by ${input.behindCount}`; +} + function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { if (icon === "commit") return ; if (icon === "push") return ; @@ -54,21 +139,20 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] { - return ["git", "status", gitCwd]; -} - -function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null) { - return queryOptions({ - queryKey: getGitStatusQueryKey(gitCwd), - queryFn: async () => { - if (!api || !gitCwd) { - throw new Error("Git status is unavailable."); - } - return api.git.status({ cwd: gitCwd }); - }, - enabled: !!api && !!gitCwd, - }); +function buildGitActionProgressStages(input: { + action: GitStackedAction; + hasCustomCommitMessage: boolean; +}): string[] { + const commitStages = input.hasCustomCommitMessage + ? ["Committing..."] + : ["Generating commit message...", "Committing..."]; + if (input.action === "commit") { + return commitStages; + } + if (input.action === "commit_push") { + return [...commitStages, "Pushing..."]; + } + return [...commitStages, "Pushing...", "Creating PR..."]; } export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { @@ -80,41 +164,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp gitStatusQueryOptions(api, gitCwd), ); - const { data: branchList = null } = useQuery({ - queryKey: ["git", "branches", gitCwd], - queryFn: async () => { - if (!api || !gitCwd) throw new Error("Git branches are unavailable."); - return api.git.listBranches({ cwd: gitCwd }); - }, - enabled: !!api && !!gitCwd, - }); + const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd)); + // Default to true while loading so we don't flash init controls. + const isRepo = branchList?.isRepo ?? true; - const runImmediateGitActionMutation = useMutation({ - mutationFn: async ({ - action, - commitMessage, - }: { - action: GitStackedAction; - commitMessage?: string; - }) => { - if (!api || !gitCwd) { - throw new Error("Git action is unavailable."); - } - return api.git.runStackedAction({ cwd: gitCwd, action, ...(commitMessage ? { commitMessage } : {}) }); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["git"] }); - }, - }); - const pullMutation = useMutation({ - mutationFn: async () => { - if (!api || !gitCwd) throw new Error("Git pull is unavailable."); - return api.git.pull({ cwd: gitCwd }); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["git"] }); - }, - }); + const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient })); + + const runImmediateGitActionMutation = useMutation( + gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }), + ); + const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient })); const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; const isDefaultBranch = useMemo(() => { @@ -129,13 +188,20 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp [gitStatus, isGitActionRunning], ); const quickAction = useMemo( - () => resolveQuickAction(gitStatus, isGitActionRunning), - [gitStatus, isGitActionRunning], + () => resolveQuickAction(gitStatus, isGitActionRunning, isDefaultBranch), + [gitStatus, isDefaultBranch, isGitActionRunning], + ); + const quickActionDisabledReason = quickAction.disabled + ? (quickAction.hint ?? "This action is currently unavailable.") + : null; + const [isQuickActionPopoverOpen, setIsQuickActionPopoverOpen] = useState(false); + const [openDisabledMenuItemId, setOpenDisabledMenuItemId] = useState( + null, ); const refreshGitStatus = useCallback(async () => { if (!api || !gitCwd) return; - await queryClient.invalidateQueries({ queryKey: ["git"] }); + await invalidateGitQueries(queryClient); }, [api, gitCwd, queryClient]); const maybeConfirmPushToDefaultBranch = useCallback( @@ -184,31 +250,60 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp const confirmed = await maybeConfirmPushToDefaultBranch(action); if (!confirmed) return; + const progressStages = buildGitActionProgressStages({ + action, + hasCustomCommitMessage: !!commitMessage?.trim(), + }); + const progressToastId = toastManager.add({ + type: "loading", + title: progressStages[0] ?? "Running git action...", + timeout: 0, + }); + + let stageIndex = 0; + const stageInterval = setInterval(() => { + stageIndex = Math.min(stageIndex + 1, progressStages.length - 1); + toastManager.update(progressToastId, { + title: progressStages[stageIndex] ?? "Running git action...", + type: "loading", + timeout: 0, + }); + }, 1100); + + const stopProgressUpdates = () => { + clearInterval(stageInterval); + }; + const promise = runImmediateGitActionMutation.mutateAsync({ action, ...(commitMessage ? { commitMessage } : {}), }); - toastManager.promise(promise, { - loading: { - title: - action === "commit" - ? "Committing..." - : action === "commit_push" - ? "Pushing..." - : "Creating PR...", - }, - success: (result) => { - const prUrl = result.pr.url ?? gitStatus?.openPr?.url; - const shouldOfferCreatePrCta = - action === "commit_push" && - !prUrl && - result.push.status === "pushed"; - return { - title: "Done", - description: describeGitResult(result), - ...(prUrl + + try { + const result = await promise; + stopProgressUpdates(); + + const prUrl = result.pr.url ?? gitStatus?.openPr?.url; + const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; + const shouldOfferOpenPrCta = + (action === "commit_push" || action === "commit_push_pr") && !!prUrl; + const shouldOfferCreatePrCta = + action === "commit_push" && !prUrl && result.push.status === "pushed"; + + toastManager.update(progressToastId, { + type: "success", + title: "Done", + description: describeGitResult(result), + timeout: 10_000, + ...(shouldOfferPushCta + ? { + actionProps: { + children: "Push", + onClick: () => setActiveDialogAction("push"), + }, + } + : shouldOfferOpenPrCta ? { - timeout: 10_000, actionProps: { children: "Open PR", onClick: () => void api?.shell.openExternal(prUrl), @@ -216,21 +311,21 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp } : shouldOfferCreatePrCta ? { - timeout: 10_000, actionProps: { children: "Create PR", onClick: () => setActiveDialogAction("create_pr"), }, } : {}), - }; - }, - error: (err) => ({ + }); + } catch (err) { + stopProgressUpdates(); + toastManager.update(progressToastId, { + type: "error", title: "Action failed", description: err instanceof Error ? err.message : "An error occurred.", - }), - }); - void promise.catch(() => undefined); + }); + } }, [ api, @@ -309,6 +404,27 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp }); }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]); + const openChangedFileInEditor = useCallback( + (filePath: string) => { + if (!api || !gitCwd) { + toastManager.add({ + type: "error", + title: "Editor opening is unavailable.", + }); + return; + } + const target = resolvePathLinkTarget(filePath, gitCwd); + void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, + [api, gitCwd], + ); + const dialogTitle = activeDialogAction === "commit" ? "Commit changes" @@ -321,65 +437,144 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp : activeDialogAction === "push" ? "Push this branch now. If this is the default branch, you'll be asked to confirm." : "Create a pull request using generated title/body content."; + const showCompactStatusCard = + !!gitStatus && gitStatus.hasWorkingTreeChanges && !gitStatus.hasUpstream; if (!gitCwd) return null; return ( <> - + {!isRepo ? ( - - { - if (open) void refreshGitStatus(); - }} - > - } - disabled={isGitActionRunning} - > - - - {gitActionMenuItems.map((item) => ( - { - openDialogForMenuItem(item); - }} + ) : ( + + {quickActionDisabledReason ? ( + + setIsQuickActionPopoverOpen(false)} + onFocus={() => setIsQuickActionPopoverOpen(true)} + onMouseEnter={() => setIsQuickActionPopoverOpen(true)} + onMouseLeave={() => setIsQuickActionPopoverOpen(false)} + tabIndex={0} + /> + } > - - {item.label} - - ))} - {gitStatus?.branch === null && ( -

- Detached HEAD: create and checkout a branch to enable push and PR actions. -

- )} - {gitStatus && - gitStatus.branch !== null && - !gitStatus.hasWorkingTreeChanges && - gitStatus.behindCount > 0 && ( + + + + {quickActionDisabledReason} + + + ) : ( + + )} + + { + if (open) void refreshGitStatus(); + if (!open) setOpenDisabledMenuItemId(null); + }} + > + } + disabled={isGitActionRunning} + > + + + {gitActionMenuItems.map((item) => { + const disabledReason = getMenuActionDisabledReason(item, gitStatus, isGitActionRunning); + if (item.disabled && disabledReason) { + return ( + setOpenDisabledMenuItemId(open ? item.id : null)} + > + setOpenDisabledMenuItemId(null)} + onFocus={() => setOpenDisabledMenuItemId(item.id)} + onMouseEnter={() => setOpenDisabledMenuItemId(item.id)} + onMouseLeave={() => setOpenDisabledMenuItemId(null)} + tabIndex={0} + /> + } + > + + + {item.label} + + + + {disabledReason} + + + ); + } + + return ( + { + openDialogForMenuItem(item); + }} + > + + {item.label} + + ); + })} + {gitStatus?.branch === null && (

- Behind upstream. Pull/rebase first. + Detached HEAD: create and checkout a branch to enable push and PR actions.

)} - {gitStatusError && ( -

{gitStatusError.message}

- )} -
-
- + {gitStatus && + gitStatus.branch !== null && + !gitStatus.hasWorkingTreeChanges && + gitStatus.behindCount > 0 && ( +

+ Behind upstream. Pull/rebase first. +

+ )} + {gitStatusError && ( +

{gitStatusError.message}

+ )} +
+
+
+ )} {dialogDescription} -
-

- Branch:{" "} - {gitStatus?.branch ?? "(detached HEAD)"} -

-

- Working tree:{" "} - {gitStatus?.hasWorkingTreeChanges ? "Has changes" : "Clean"} -

-

- Ahead/behind:{" "} - {gitStatus ? `${gitStatus.aheadCount}/${gitStatus.behindCount}` : "unknown"} -

+
+
+ Branch + {gitStatus?.branch ?? "(detached HEAD)"} + {!showCompactStatusCard && ( + <> + Upstream + + {gitStatus + ? formatUpstreamStatus({ + hasUpstream: gitStatus.hasUpstream, + aheadCount: gitStatus.aheadCount, + behindCount: gitStatus.behindCount, + }) + : "unknown"} + + Working tree + + {!gitStatus || !gitStatus.hasWorkingTreeChanges + ? "Clean" + : `${gitStatus.workingTree.files.length} file(s)`} + + Diff + + {!gitStatus || !gitStatus.hasWorkingTreeChanges ? ( + "none" + ) : ( + <> + +{gitStatus.workingTree.insertions} + / + -{gitStatus.workingTree.deletions} + + )} + + + )} +
+
+

Files

+ {!gitStatus || gitStatus.workingTree.files.length === 0 ? ( +

none

+ ) : ( +
+ {gitStatus.workingTree.files.slice(0, 6).map((file) => ( + + ))} + {gitStatus.workingTree.files.length > 6 && ( + + +{gitStatus.workingTree.files.length - 6} more + + )} +
+ )} +
{activeDialogAction === "commit" && (
diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 7485f6057e361f8e9f2dc033a7ec0c633f6f5140..39f20030d3696e5e8962f9889deb4873211b298d 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -37,7 +37,7 @@ interface ToastProviderProps extends Toast.Provider.Props { function ToastProvider({ children, - position = "bottom-right", + position = "top-right", ...props }: ToastProviderProps) { return ( @@ -48,7 +48,7 @@ function ToastProvider({ ); } -function Toasts({ position = "bottom-right" }: { position: ToastPosition }) { +function Toasts({ position = "top-right" }: { position: ToastPosition }) { const { toasts } = Toast.useToastManager(); const isTop = position.startsWith("top"); @@ -56,9 +56,9 @@ function Toasts({ position = "bottom-right" }: { position: ToastPosition }) { ["git", "status", cwd] as const, + branches: (cwd: string | null) => ["git", "branches", cwd] as const, +}; + +export function invalidateGitQueries(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); +} + +export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | null) { + return queryOptions({ + queryKey: gitQueryKeys.status(cwd), + queryFn: async () => { + if (!api || !cwd) { + throw new Error("Git status is unavailable."); + } + return api.git.status({ cwd }); + }, + enabled: !!api && !!cwd, + }); +} + +export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string | null) { + return queryOptions({ + queryKey: gitQueryKeys.branches(cwd), + queryFn: async () => { + if (!api || !cwd) { + throw new Error("Git branches are unavailable."); + } + return api.git.listBranches({ cwd }); + }, + enabled: !!api && !!cwd, + }); +} + +export function gitInitMutationOptions(input: { + api: NativeApi | undefined; + cwd: string | null; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationFn: async () => { + if (!input.api || !input.cwd) throw new Error("Git init is unavailable."); + return input.api.git.init({ cwd: input.cwd }); + }, + onSuccess: async () => { + await invalidateGitQueries(input.queryClient); + }, + }); +} + +export function gitCheckoutMutationOptions(input: { + api: NativeApi | undefined; + cwd: string | null; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationFn: async (branch: string) => { + if (!input.api || !input.cwd) throw new Error("Git checkout is unavailable."); + return input.api.git.checkout({ cwd: input.cwd, branch }); + }, + onSuccess: async () => { + await invalidateGitQueries(input.queryClient); + }, + }); +} + +export function gitCreateBranchAndCheckoutMutationOptions(input: { + api: NativeApi | undefined; + cwd: string | null; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationFn: async (branch: string) => { + if (!input.api || !input.cwd) throw new Error("Git branch creation is unavailable."); + await input.api.git.createBranch({ cwd: input.cwd, branch }); + return input.api.git.checkout({ cwd: input.cwd, branch }); + }, + onSuccess: async () => { + await invalidateGitQueries(input.queryClient); + }, + }); +} + +export function gitRunStackedActionMutationOptions(input: { + api: NativeApi | undefined; + cwd: string | null; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationFn: async ({ + action, + commitMessage, + }: { + action: GitStackedAction; + commitMessage?: string; + }) => { + if (!input.api || !input.cwd) { + throw new Error("Git action is unavailable."); + } + return input.api.git.runStackedAction({ + cwd: input.cwd, + action, + ...(commitMessage ? { commitMessage } : {}), + }); + }, + onSuccess: async () => { + await invalidateGitQueries(input.queryClient); + }, + }); +} + +export function gitPullMutationOptions(input: { + api: NativeApi | undefined; + cwd: string | null; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationFn: async () => { + if (!input.api || !input.cwd) throw new Error("Git pull is unavailable."); + return input.api.git.pull({ cwd: input.cwd }); + }, + onSuccess: async () => { + await invalidateGitQueries(input.queryClient); + }, + }); +} diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 305fe666f07f3a5cd80e486959dd81b26faf42e2..01cdc1936109d42f3b9d218abf35ae6ff99343a1 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -25,6 +25,14 @@ describe("git contracts", () => { const parsed = gitStatusResultSchema.parse({ branch: "feature/git-actions", hasWorkingTreeChanges: true, + workingTree: { + files: [ + { path: "src/git.ts", insertions: 8, deletions: 2 }, + { path: "src/git.test.ts", insertions: 4, deletions: 1 }, + ], + insertions: 12, + deletions: 3, + }, hasUpstream: false, aheadCount: 0, behindCount: 0, @@ -32,6 +40,8 @@ describe("git contracts", () => { }); expect(parsed.branch).toBe("feature/git-actions"); expect(parsed.hasWorkingTreeChanges).toBe(true); + expect(parsed.workingTree.files[0]?.insertions).toBe(8); + expect(parsed.workingTree.files).toHaveLength(2); }); it("accepts supported stacked actions", () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index d58833bcb930f672da60e99ce0ca6847d562b69a..d971d06556fa805ed02f10bf899d2fa3b4b24af1 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -75,6 +75,17 @@ export const gitPullInputSchema = z.object({ export const gitStatusResultSchema = z.object({ branch: z.string().min(1).nullable(), hasWorkingTreeChanges: z.boolean(), + workingTree: z.object({ + files: z.array( + z.object({ + path: z.string().min(1), + insertions: z.number().int().nonnegative(), + deletions: z.number().int().nonnegative(), + }), + ), + insertions: z.number().int().nonnegative(), + deletions: z.number().int().nonnegative(), + }), hasUpstream: z.boolean(), aheadCount: z.number().int().nonnegative(), behindCount: z.number().int().nonnegative(), -- 2.54.0