diff --git a/src/main/sharedSettingsFile.test.ts b/src/main/sharedSettingsFile.test.ts index bcc62609..e8197570 100644 --- a/src/main/sharedSettingsFile.test.ts +++ b/src/main/sharedSettingsFile.test.ts @@ -71,6 +71,7 @@ describe("sharedSettingsFile", () => { autoShowTerminalPanel: true, gitReviewMode: "panel", prCreateMode: "dialog", + commitDefaultAction: "commit-push", providerConfigs: {}, lastPresentationModeByAgent: {}, lastUsedProjectDirs: {}, @@ -156,6 +157,7 @@ describe("sharedSettingsFile", () => { autoShowTerminalPanel: true, gitReviewMode: "panel", prCreateMode: "dialog", + commitDefaultAction: "commit-push", providerConfigs: {}, lastPresentationModeByAgent: {}, lastUsedProjectDirs: {}, diff --git a/src/renderer/state/sharedSettingsStore.ts b/src/renderer/state/sharedSettingsStore.ts index ee7bea76..a81b2118 100644 --- a/src/renderer/state/sharedSettingsStore.ts +++ b/src/renderer/state/sharedSettingsStore.ts @@ -9,6 +9,7 @@ import { import type { GitReviewMode, AgentInstanceConfig, + CommitDefaultAction, InstalledAcpRegistryAgent, NewThreadMode, NotificationFilter, @@ -55,6 +56,7 @@ interface SharedSettingsState extends SharedSettings { setAutoShowTerminalPanel: (value: boolean) => void; setGitReviewMode: (value: GitReviewMode) => void; setPrCreateMode: (value: PrCreateMode) => void; + setCommitDefaultAction: (value: CommitDefaultAction) => void; setEditorLspEnabled: (value: boolean) => void; setSearchUseIgnoreFiles: (value: boolean) => void; setSearchExclude: (value: Record) => void; @@ -318,6 +320,11 @@ export const useSharedSettings = create()((set, get) => ({ set({ prCreateMode }); persistSettings(selectSharedSettings(get())); }, + setCommitDefaultAction: (commitDefaultAction) => { + if (get().commitDefaultAction === commitDefaultAction) return; + set({ commitDefaultAction }); + persistSettings(selectSharedSettings(get())); + }, setEditorLspEnabled: (editorLspEnabled) => { set({ editorLspEnabled }); persistSettings(selectSharedSettings(get())); @@ -557,6 +564,7 @@ function selectSharedSettings(state: SharedSettingsState): SharedSettingsInput { autoShowTerminalPanel: state.autoShowTerminalPanel, gitReviewMode: state.gitReviewMode, prCreateMode: state.prCreateMode, + commitDefaultAction: state.commitDefaultAction, providerConfigs: state.providerConfigs, lastPresentationModeByAgent: state.lastPresentationModeByAgent, lastUsedProjectDirs: state.lastUsedProjectDirs, diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.test.tsx b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.test.tsx index 322c1710..bd6858fa 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.test.tsx +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.test.tsx @@ -102,6 +102,7 @@ vi.mock("@heroui/react", () => { Label: (props: { children: ReactNode }) => {props.children}, ListBox, Select, + Separator: () => , Surface: Wrapper, Tooltip, diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.tsx b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.tsx index f137f050..72e8002e 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.tsx +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.tsx @@ -10,8 +10,8 @@ import { PanelLeft, PanelLeftClose, } from "lucide-react"; -import { Button, ButtonGroup, Dropdown, Label, Modal } from "@heroui/react"; -import type { GitBranchInfo, GitStatusResult, Project } from "@/shared/contracts"; +import { Button, ButtonGroup, Dropdown, Label, Modal, Separator } from "@heroui/react"; +import type { GitBranchInfo, GitStatusResult, PrCreateMode, Project } from "@/shared/contracts"; import { getProjectAgentStatuses } from "@/shared/agentStatus"; import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; import { useGitStore } from "@/renderer/state/gitStore"; @@ -102,6 +102,9 @@ export function GitReviewSidebar(props: { isWsl ? s.wslCommitGenProvider : s.commitGenProvider, ); const prCreateMode = useSharedSettings((s) => s.prCreateMode); + const setPrCreateMode = useSharedSettings((s) => s.setPrCreateMode); + const commitDefaultAction = useSharedSettings((s) => s.commitDefaultAction); + const setCommitDefaultAction = useSharedSettings((s) => s.setCommitDefaultAction); // Treat "unknown" as "might be GitHub" — covers SSH host aliases where the // remote URL hostname doesn't contain "github" but resolves to github.com. @@ -160,6 +163,7 @@ export function GitReviewSidebar(props: { handlePullFromSource, handleAbortMerge, handleCreatePr, + handleCommitAndCreatePr, handleMergePr, handleClosePr, handleMarkPrReady, @@ -208,9 +212,15 @@ export function GitReviewSidebar(props: { ); const showPullFromSource = Boolean(effectiveBranch && sourceBranch && sourceAhead > 0); const isPushed = hasTracking && ahead === 0; - const showCreatePrButton = Boolean( - showPrSection && ghAvailable && isPushed && sourceBranch && (!prState || prState === "closed"), + // Shared PR eligibility: a GitHub repo with a target branch and no open PR. + const prEligible = Boolean( + showPrSection && ghAvailable && sourceBranch && (!prState || prState === "closed"), ); + const showCreatePrButton = prEligible && isPushed; + // Whether the one-click "Commit & Create PR" action is offered. Unlike + // showCreatePrButton this does NOT require an already-pushed branch (it only + // needs a remote) — the combined action pushes as part of its flow. + const canCreatePr = prEligible && hasRemote; const [createPrModalOpen, setCreatePrModalOpen] = useState(false); // In "auto" mode the Create PR button skips the dialog: it auto-generates the // title/body and creates the PR in one click (handleCreatePr handles the @@ -220,10 +230,19 @@ export function GitReviewSidebar(props: { // derived once here and shared. const isAutoPrMode = prCreateMode === "auto"; const createPrPending = isAutoPrMode && prLoading; - const onCreatePrPress = () => { - if (isAutoPrMode) void handleCreatePr(false); + const runPrMode = (prMode: PrCreateMode) => { + if (prMode === "auto") void handleCreatePr(false); else setCreatePrModalOpen(true); }; + const onCreatePrPress = () => runPrMode(prCreateMode); + // Picking the other mode from the split-button menu both runs it and makes + // it the sticky default (the same field the Git settings select drives). + const selectPrMode = (prMode: PrCreateMode) => { + setPrCreateMode(prMode); + runPrMode(prMode); + }; + const altPrMode: PrCreateMode = isAutoPrMode ? "dialog" : "auto"; + const altPrModeLabel = isAutoPrMode ? "Create PR…" : "Create PR (Auto)"; const createPrButtonContent = ( <> {createPrPending ? : } @@ -441,14 +460,19 @@ export function GitReviewSidebar(props: { setCommitMessage={setCommitMessage} canCommitStaged={canCommitStaged} canGenerateMessage={canGenerateMessage} + canCreatePr={canCreatePr} + commitDefaultAction={commitDefaultAction} + setCommitDefaultAction={setCommitDefaultAction} isCommitting={isCommitting} isGenerating={isGenerating} isSyncing={isSyncing} + prLoading={prLoading} isPullingFromSource={isPullingFromSource} showPullFromSource={showPullFromSource} sourceBranch={sourceBranch} sourceAhead={sourceAhead} handleCommit={handleCommit} + handleCommitAndCreatePr={handleCommitAndCreatePr} handleGenerateMessage={handleGenerateMessage} handleSyncOrPush={handleSyncOrPush} handleSyncAction={handleSyncAction} @@ -472,61 +496,60 @@ export function GitReviewSidebar(props: { {showCreatePrButton && ( - {showMergeActions ? ( - - - - - - { - if (key === "merge-only") void handleMergeOnly(); - if (key === "merge-and-remove") void handleMergeAndRemove(); - }} - > - - - - - - - - - - - - - ) : ( + - )} + + + + { + if (key === "pr-auto") selectPrMode("auto"); + else if (key === "pr-dialog") selectPrMode("dialog"); + else if (key === "merge-only") void handleMergeOnly(); + else if (key === "merge-and-remove") void handleMergeAndRemove(); + }} + > + + + + + {showMergeActions ? ( + <> + + + + + + + + + + + ) : null} + + + + )} diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/CommitSyncPanel.tsx b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/CommitSyncPanel.tsx index a344583e..f17ab125 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/CommitSyncPanel.tsx +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/CommitSyncPanel.tsx @@ -1,8 +1,30 @@ -import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, Lock, Sparkles } from "lucide-react"; +import type { ReactNode } from "react"; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + ChevronDown, + GitPullRequest, + Lock, + Sparkles, +} from "lucide-react"; import { Button, ButtonGroup, Dropdown, Label, Tooltip } from "@heroui/react"; +import type { CommitDefaultAction } from "@/shared/contracts"; import { PixelLoader, TextArea } from "@/renderer/components/common"; import type { GitSyncCommand } from "@/renderer/actions/gitCommandRunner"; import { GitReviewSection } from "./GitReviewSection"; +import { + COMMIT_ACTION_LABELS, + getAvailableCommitActions, + resolvePrimaryCommitAction, +} from "./commitActions"; + +// Static icon per commit action — the visual twin of COMMIT_ACTION_LABELS. +const COMMIT_ACTION_ICONS: Record = { + commit: , + "commit-push": , + "commit-push-pr": , +}; export function CommitSyncPanel(props: { hasAnyChanges: boolean; @@ -16,14 +38,19 @@ export function CommitSyncPanel(props: { setCommitMessage: (msg: string) => void; canCommitStaged: boolean; canGenerateMessage: boolean; + canCreatePr: boolean; + commitDefaultAction: CommitDefaultAction; + setCommitDefaultAction: (action: CommitDefaultAction) => void; isCommitting: boolean; isGenerating: boolean; isSyncing: boolean; + prLoading: boolean; isPullingFromSource: boolean; showPullFromSource: boolean; sourceBranch: string | null; sourceAhead: number; - handleCommit: (addAll: boolean, pushAfter?: boolean) => Promise; + handleCommit: (addAll: boolean, pushAfter?: boolean) => Promise; + handleCommitAndCreatePr: (addAll: boolean) => Promise; handleGenerateMessage: () => Promise; handleSyncOrPush: () => Promise; handleSyncAction: (key: GitSyncCommand) => Promise; @@ -41,20 +68,49 @@ export function CommitSyncPanel(props: { setCommitMessage, canCommitStaged, canGenerateMessage, + canCreatePr, + commitDefaultAction, + setCommitDefaultAction, isCommitting, isGenerating, isSyncing, + prLoading, isPullingFromSource, showPullFromSource, sourceBranch, sourceAhead, handleCommit, + handleCommitAndCreatePr, handleGenerateMessage, handleSyncOrPush, handleSyncAction, handlePullFromSource, } = props; + // Resolve which commit action the primary button performs. The user's + // sticky last-used choice wins when it's actually available; otherwise we + // degrade to the strongest available action (push needs a remote, PR needs + // a target branch) without overwriting their stored preference. + const addAll = !hasStagedChanges; + const availableCommitActions = getAvailableCommitActions({ hasRemote, canCreatePr }); + const primaryCommitAction = resolvePrimaryCommitAction(commitDefaultAction, { + hasRemote, + canCreatePr, + }); + const runCommitAction = (action: CommitDefaultAction) => { + if (action === "commit") return void handleCommit(addAll); + if (action === "commit-push") return void handleCommit(addAll, true); + return void handleCommitAndCreatePr(addAll); + }; + // Picking from the dropdown both runs the action and makes it the new + // sticky default; pressing the primary button only runs it. + const selectCommitAction = (action: CommitDefaultAction) => { + setCommitDefaultAction(action); + runCommitAction(action); + }; + const primaryCommitPending = + isCommitting || (primaryCommitAction === "commit-push-pr" && prLoading); + return ( {hasAnyChanges ? ( @@ -77,7 +133,7 @@ export function CommitSyncPanel(props: { onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); - if (canCommitStaged) void handleCommit(!hasStagedChanges, hasRemote); + if (canCommitStaged) runCommitAction(primaryCommitAction); } }} /> @@ -102,30 +158,32 @@ export function CommitSyncPanel(props: { {(() => { + const secondaryActions = availableCommitActions.filter( + (action) => action !== primaryCommitAction, + ); + const hasMenuItems = secondaryActions.length > 0 || showPullFromSource; + const commitButton = ( ); - const hasMenuItems = hasRemote || showPullFromSource; if (!hasMenuItems) { return
{commitButton}
; } @@ -147,20 +205,24 @@ export function CommitSyncPanel(props: { { - if (key === "commit-only") void handleCommit(!hasStagedChanges); - if (key === "pull-from-source") void handlePullFromSource(); + if (key === "pull-from-source") { + void handlePullFromSource(); + return; + } + selectCommitAction(key as CommitDefaultAction); }} > - {hasRemote ? ( + {secondaryActions.map((action) => ( - - + {COMMIT_ACTION_ICONS[action]} + - ) : null} + ))} {showPullFromSource ? ( { + it("offers only Commit with no remote", () => { + expect(getAvailableCommitActions({ hasRemote: false, canCreatePr: false })).toEqual(["commit"]); + }); + + it("adds Commit & Push once a remote exists", () => { + expect(getAvailableCommitActions({ hasRemote: true, canCreatePr: false })).toEqual([ + "commit", + "commit-push", + ]); + }); + + it("adds Commit & Create PR when a PR can be opened", () => { + expect(getAvailableCommitActions({ hasRemote: true, canCreatePr: true })).toEqual([ + "commit", + "commit-push", + "commit-push-pr", + ]); + }); +}); + +describe("resolvePrimaryCommitAction", () => { + it("keeps the sticky default when it's available", () => { + expect( + resolvePrimaryCommitAction("commit-push-pr", { hasRemote: true, canCreatePr: true }), + ).toBe("commit-push-pr"); + }); + + it("falls back to Commit & Push when the PR target disappears", () => { + // Sticky preference is commit-push-pr, but there's no PR target right now — + // degrade to push without forgetting the user's choice. + expect( + resolvePrimaryCommitAction("commit-push-pr", { hasRemote: true, canCreatePr: false }), + ).toBe("commit-push"); + }); + + it("falls back to Commit when there's no remote at all", () => { + expect( + resolvePrimaryCommitAction("commit-push", { hasRemote: false, canCreatePr: false }), + ).toBe("commit"); + expect( + resolvePrimaryCommitAction("commit-push-pr", { hasRemote: false, canCreatePr: false }), + ).toBe("commit"); + }); + + it("honours a plain Commit preference even when more is available", () => { + expect(resolvePrimaryCommitAction("commit", { hasRemote: true, canCreatePr: true })).toBe( + "commit", + ); + }); +}); diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/commitActions.ts b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/commitActions.ts new file mode 100644 index 00000000..a3e873b5 --- /dev/null +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/commitActions.ts @@ -0,0 +1,42 @@ +import type { CommitDefaultAction } from "@/shared/contracts"; + +export interface CommitActionAvailability { + /** A remote exists, so the changes can be pushed. */ + hasRemote: boolean; + /** A GitHub PR can be opened: remote + target branch + no open PR yet. */ + canCreatePr: boolean; +} + +export const COMMIT_ACTION_LABELS: Record = { + commit: "Commit", + "commit-push": "Commit & Push", + "commit-push-pr": "Commit & Create PR", +}; + +/** + * Commit actions offered by the commit split-button, in ascending order of + * scope. `commit` is always available; `commit-push` needs a remote; + * `commit-push-pr` additionally needs a PR target with no open PR yet. + */ +export function getAvailableCommitActions( + availability: CommitActionAvailability, +): CommitDefaultAction[] { + const actions: CommitDefaultAction[] = ["commit"]; + if (availability.hasRemote) actions.push("commit-push"); + if (availability.canCreatePr) actions.push("commit-push-pr"); + return actions; +} + +/** + * The primary commit action: the user's sticky last-used default when it's + * available, otherwise the strongest available fallback (push needs a remote). + * Never mutates the stored preference — a temporarily-unavailable default is + * just displayed as its fallback until it becomes available again. + */ +export function resolvePrimaryCommitAction( + preferred: CommitDefaultAction, + availability: CommitActionAvailability, +): CommitDefaultAction { + if (getAvailableCommitActions(availability).includes(preferred)) return preferred; + return availability.hasRemote ? "commit-push" : "commit"; +} diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts index e3ac0a7b..54f9b387 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts @@ -183,7 +183,11 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { }); } - async function handleCommit(addAll: boolean, pushAfter = false): Promise { + // Returns true when the commit (and optional push) fully succeeded, so + // callers that chain further work — e.g. the combined "Commit & Create PR" + // action — only proceed when there's actually something pushed to open a PR + // against. + async function handleCommit(addAll: boolean, pushAfter = false): Promise { setIsCommitting(true); try { let message = commitMessage.trim(); @@ -235,7 +239,7 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { } finally { setIsSyncing(false); } - return; + return true; } onRefresh(); @@ -246,6 +250,7 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { .gitFetch({ projectLocation: project.location, remote: "origin", prune: false }) .catch(() => undefined) .finally(() => onRefresh()); + return true; } catch (err) { console.error("[git] commit failed", err); const { summary, details } = friendlyErrorWithDetail(err); @@ -261,6 +266,7 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { } else { toast.danger(summary); } + return false; } finally { setIsCommitting(false); } @@ -548,6 +554,16 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { } } + // One-click "Commit & Create PR": commit + push, then — only if that + // succeeded — auto-create the PR. The PR step always auto-generates (never + // opens the dialog), so this stays a single uninterrupted action regardless + // of the prCreateMode setting. + async function handleCommitAndCreatePr(addAll: boolean): Promise { + const committed = await handleCommit(addAll, true); + if (!committed) return; + await handleCreatePr(false); + } + async function handleGeneratePrSummary(): Promise { const targetBranch = prTargetBranch || sourceBranch; if (!effectiveBranch || !targetBranch) return; @@ -604,6 +620,7 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { handleAbortMerge, handleFinishMerge, handleCreatePr, + handleCommitAndCreatePr, handleMergePr: writeActions.handleMergePr, handleClosePr: writeActions.handleClosePr, handleMarkPrReady: writeActions.handleMarkPrReady, diff --git a/src/renderer/views/SettingsOverlay/parts/GitSettings.tsx b/src/renderer/views/SettingsOverlay/parts/GitSettings.tsx index 197fd5a3..ccb938f7 100644 --- a/src/renderer/views/SettingsOverlay/parts/GitSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/GitSettings.tsx @@ -30,8 +30,8 @@ export function GitSettings() { />