From 9accd1ffb758431dd80b8c1b79ceb5dc618abaca Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Feb 2026 14:12:45 -0800
Subject: [PATCH] Improve git action menu and modal workflow
- Split Git actions into context-aware Commit, Push, and PR menu items
- Add modal action selection with clearer availability and disabled-state guidance
- Migrate git status and immediate actions to React Query and add a custom GitHub icon
Co-authored-by: codex
---
apps/web/src/components/GitActionsControl.tsx | 566 +++++++++++-------
apps/web/src/components/Icons.tsx | 15 +
2 files changed, 354 insertions(+), 227 deletions(-)
create mode 100644 apps/web/src/components/Icons.tsx
diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx
index 32c660546c3969e6c5df35844227c5708b4614e2..cbd0758aae428de0e2cdb8945bd2680ccfa27a8b 100644
--- a/apps/web/src/components/GitActionsControl.tsx
+++ b/apps/web/src/components/GitActionsControl.tsx
@@ -4,36 +4,44 @@ import {
type GitStatusResult,
type NativeApi,
} from "@t3tools/contracts";
+import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CheckIcon,
CircleIcon,
CloudUploadIcon,
GitCommitIcon,
- GithubIcon,
Loader2Icon,
MinusIcon,
XIcon,
} from "lucide-react";
+import { GitHubIcon } from "./Icons";
interface GitActionsControlProps {
api: NativeApi | undefined;
gitCwd: string | null;
}
+type GitActionIconName = "commit" | "push" | "pr";
+
interface GitActionMenuItem {
- id: GitStackedAction;
+ id: "commit" | "push" | "pr";
label: string;
disabled: boolean;
- icon: "commit" | "push" | "pr";
+ icon: GitActionIconName;
+ kind: "open_modal" | "run_action" | "open_pr";
+ action?: GitStackedAction;
}
-type GitProgressStepStatus =
- | "pending"
- | "running"
- | "completed"
- | "skipped"
- | "failed";
+interface GitModalActionOption {
+ action: GitStackedAction;
+ label: string;
+ disabled: boolean;
+ icon: GitActionIconName;
+ detail?: string;
+}
+
+type GitProgressStepStatus = "pending" | "running" | "completed" | "skipped" | "failed";
interface GitProgressStep {
id: "generate" | "commit" | "push" | "pr";
@@ -42,7 +50,7 @@ interface GitProgressStep {
detail?: string;
}
-function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boolean }) {
+function GitActionIcon(props: { icon: GitActionIconName; disabled: boolean }) {
const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85";
if (props.icon === "commit") {
@@ -53,13 +61,11 @@ function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boole
return ;
}
- return ;
+ return ;
}
-function gitActionModalTitle(action: GitStackedAction): string {
- if (action === "commit") return "Commit your changes";
- if (action === "commit_push") return "Commit and push changes";
- return "Commit, push and open PR";
+function gitActionModalTitle(): string {
+ return "Commit your changes";
}
function initialGitProgressSteps(
@@ -119,102 +125,198 @@ function updateProgressStep(
}
function runActionLabel(action: GitStackedAction): string {
- if (action === "commit") return "Commit";
- if (action === "commit_push") return "Commit & Push";
- return "Commit, Push & Open PR";
+ if (action === "commit") return "Commit changes";
+ if (action === "commit_push") return "Commit and push";
+ return "Commit and create PR";
}
-function isViewPrOnlyAction(
- action: GitStackedAction,
+function buildGitActionMenuItems(
gitStatus: GitStatusResult | null,
-): boolean {
- if (action !== "commit_push_pr") return false;
- if (!gitStatus?.openPr) return false;
- return !gitStatus.hasWorkingTreeChanges && gitStatus.aheadCount === 0;
+ isDisabled: boolean,
+): GitActionMenuItem[] {
+ if (!gitStatus) return [];
+
+ const hasBranch = gitStatus.branch !== null;
+ const hasOpenPr = gitStatus.openPr !== null;
+ const hasChanges = gitStatus.hasWorkingTreeChanges;
+ const hasAhead = gitStatus.aheadCount > 0;
+ const canCommit = !isDisabled && hasChanges;
+ const canPush = !isDisabled && hasBranch && !hasChanges && hasAhead;
+ const canOpenPr = !isDisabled && hasOpenPr;
+ const canCreatePr =
+ !isDisabled &&
+ hasBranch &&
+ !hasOpenPr &&
+ !hasChanges &&
+ gitStatus.hasUpstream &&
+ gitStatus.behindCount === 0;
+
+ if (!hasChanges && !hasAhead && hasOpenPr) {
+ return [
+ {
+ id: "pr",
+ label: "Open PR",
+ disabled: !canOpenPr,
+ icon: "pr",
+ kind: "open_pr",
+ },
+ ];
+ }
+
+ return [
+ {
+ id: "commit",
+ label: "Commit",
+ disabled: !canCommit,
+ icon: "commit",
+ kind: "open_modal",
+ },
+ {
+ id: "push",
+ label: "Push",
+ disabled: !canPush,
+ icon: "push",
+ kind: "run_action",
+ action: "commit_push",
+ },
+ hasOpenPr
+ ? {
+ id: "pr",
+ label: "View PR",
+ disabled: !canOpenPr,
+ icon: "pr",
+ kind: "open_pr",
+ }
+ : {
+ id: "pr",
+ label: "Create PR",
+ disabled: !canCreatePr,
+ icon: "pr",
+ kind: "run_action",
+ action: "commit_push_pr",
+ },
+ ];
+}
+
+function buildGitModalActionOptions(
+ gitStatus: GitStatusResult | null,
+ isDisabled: boolean,
+): GitModalActionOption[] {
+ const hasBranch = gitStatus?.branch !== null;
+ const hasOpenPr = gitStatus?.openPr !== null;
+
+ return [
+ {
+ action: "commit",
+ label: "Commit",
+ disabled: isDisabled,
+ icon: "commit",
+ },
+ {
+ action: "commit_push",
+ label: "Commit and push",
+ disabled: isDisabled || !hasBranch,
+ icon: "push",
+ ...(!hasBranch ? { detail: "Requires an attached branch." } : {}),
+ },
+ {
+ action: "commit_push_pr",
+ label: "Commit and create PR",
+ disabled: isDisabled || !hasBranch || hasOpenPr,
+ icon: "pr",
+ ...(!hasBranch
+ ? { detail: "Requires an attached branch." }
+ : hasOpenPr
+ ? { detail: "A PR is already open for this branch." }
+ : {}),
+ },
+ ];
+}
+
+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,
+ });
}
export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
+ const queryClient = useQueryClient();
const [isGitMenuOpen, setIsGitMenuOpen] = useState(false);
- const [isGitActionRunning, setIsGitActionRunning] = useState(false);
- const [gitStatus, setGitStatus] = useState(null);
+ const [isGitModalActionRunning, setIsGitModalActionRunning] = useState(false);
const [gitActionError, setGitActionError] = useState(null);
- const [gitModalAction, setGitModalAction] = useState(null);
+ const [isGitModalOpen, setIsGitModalOpen] = useState(false);
+ const [gitModalSelectedAction, setGitModalSelectedAction] = useState("commit");
const [gitModalCommitMessage, setGitModalCommitMessage] = useState("");
const [gitModalProgress, setGitModalProgress] = useState([]);
const [gitModalError, setGitModalError] = useState(null);
const [gitModalResult, setGitModalResult] = useState(null);
const gitMenuRef = useRef(null);
- const latestGitCwdRef = useRef(null);
+
+ const { data: gitStatus = null, error: gitStatusError } = useQuery(
+ gitStatusQueryOptions(api, gitCwd),
+ );
+
+ const runImmediateGitActionMutation = useMutation({
+ mutationFn: async (action: GitStackedAction) => {
+ if (!api || !gitCwd) {
+ throw new Error("Git action is unavailable.");
+ }
+ return api.git.runStackedAction({ cwd: gitCwd, action });
+ },
+ onMutate: () => {
+ setIsGitMenuOpen(false);
+ setGitActionError(null);
+ setGitModalError(null);
+ },
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: ["git"] });
+ },
+ onError: (error) => {
+ setGitActionError(error instanceof Error ? error.message : "Git action failed.");
+ },
+ });
+
+ const isGitActionRunning = isGitModalActionRunning || runImmediateGitActionMutation.isPending;
const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning;
- const gitActionMenuItems = useMemo(() => {
- if (!gitStatus) return [];
-
- const hasBranch = gitStatus.branch !== null;
- const hasOpenPr = gitStatus.openPr !== null;
- const canCommit = !gitBaseDisabled && gitStatus.hasWorkingTreeChanges;
- const canPush =
- !gitBaseDisabled &&
- hasBranch &&
- (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0);
- const canViewPr =
- !gitBaseDisabled &&
- hasBranch &&
- gitStatus.behindCount === 0 &&
- (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0 || hasOpenPr);
-
- return [
- {
- id: "commit",
- label: "Commit",
- disabled: !canCommit,
- icon: "commit",
- },
- {
- id: "commit_push",
- label: "Push",
- disabled: !canPush,
- icon: "push",
- },
- {
- id: "commit_push_pr",
- label: "View PR",
- disabled: !canViewPr,
- icon: "pr",
- },
- ];
- }, [gitBaseDisabled, gitStatus]);
-
- const isGitModalOpen = gitModalAction !== null;
- const gitModalPreviewSteps = useMemo(
- () =>
- gitModalAction
- ? initialGitProgressSteps(gitModalAction, gitModalCommitMessage)
- : ([] as GitProgressStep[]),
- [gitModalAction, gitModalCommitMessage],
+ const gitActionMenuItems = useMemo(
+ () => buildGitActionMenuItems(gitStatus, gitBaseDisabled),
+ [gitBaseDisabled, gitStatus],
);
- const gitModalSteps = gitModalProgress.length > 0 ? gitModalProgress : gitModalPreviewSteps;
-
- useEffect(() => {
- latestGitCwdRef.current = gitCwd;
- }, [gitCwd]);
+ const gitModalActionOptions = useMemo(
+ () => buildGitModalActionOptions(gitStatus, !gitStatus || isGitActionRunning),
+ [gitStatus, isGitActionRunning],
+ );
+ const selectedGitModalActionOption = useMemo(
+ () => gitModalActionOptions.find((option) => option.action === gitModalSelectedAction) ?? null,
+ [gitModalActionOptions, gitModalSelectedAction],
+ );
+ const visibleGitActionError = gitActionError ?? gitStatusError?.message;
+ const gitModalHasProgress = gitModalProgress.length > 0;
+ const gitModalSelectionMode = !gitModalHasProgress && gitModalResult === null;
+ const gitModalSteps = gitModalProgress;
const refreshGitStatus = useCallback(async () => {
- const requestCwd = gitCwd;
- if (!api || !requestCwd) {
- setGitStatus(null);
- return;
- }
-
- const nextStatus = await api.git.status({ cwd: requestCwd });
- if (latestGitCwdRef.current !== requestCwd) return;
- setGitStatus(nextStatus);
+ if (!api || !gitCwd) return;
+ await queryClient.invalidateQueries({ queryKey: ["git"] });
setGitActionError(null);
- }, [api, gitCwd]);
+ }, [api, gitCwd, queryClient]);
- const openGitActionModal = useCallback((action: GitStackedAction) => {
+ const openGitActionModal = useCallback(() => {
setIsGitMenuOpen(false);
- setGitModalAction(action);
+ setIsGitModalOpen(true);
+ setGitModalSelectedAction("commit");
setGitModalCommitMessage("");
setGitModalProgress([]);
setGitModalError(null);
@@ -224,7 +326,8 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
const closeGitActionModal = useCallback(() => {
if (isGitActionRunning) return;
- setGitModalAction(null);
+ setIsGitModalOpen(false);
+ setGitModalSelectedAction("commit");
setGitModalCommitMessage("");
setGitModalProgress([]);
setGitModalError(null);
@@ -232,44 +335,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
}, [isGitActionRunning]);
const runGitActionImmediately = useCallback(
- async (action: GitStackedAction) => {
- if (!api || !gitCwd) return;
- const actionCwd = gitCwd;
-
- setIsGitMenuOpen(false);
- setGitActionError(null);
- setGitModalError(null);
- setIsGitActionRunning(true);
-
- try {
- await api.git.runStackedAction({
- cwd: actionCwd,
- action,
- });
- } catch (error) {
- setGitActionError(error instanceof Error ? error.message : "Git action failed.");
- } finally {
- setIsGitActionRunning(false);
- try {
- if (latestGitCwdRef.current === actionCwd) {
- await refreshGitStatus();
- }
- } catch {
- setGitStatus(null);
- }
- }
+ (action: GitStackedAction) => {
+ runImmediateGitActionMutation.mutate(action);
},
- [api, gitCwd, refreshGitStatus],
+ [runImmediateGitActionMutation],
);
+ const openExistingPr = useCallback(() => {
+ setIsGitMenuOpen(false);
+ setGitActionError(null);
+
+ const prUrl = gitStatus?.openPr?.url ?? null;
+ if (!prUrl) {
+ setGitActionError("No open PR found for the current branch.");
+ return;
+ }
+
+ const popup = window.open(prUrl, "_blank", "noopener,noreferrer");
+ if (!popup) {
+ setGitActionError("Unable to open PR. Allow popups and try again.");
+ }
+ }, [gitStatus?.openPr?.url]);
+
const runGitAction = useCallback(async () => {
- if (!api || !gitCwd || !gitModalAction) return;
+ if (!api || !gitCwd || !isGitModalOpen) return;
+ if (!selectedGitModalActionOption || selectedGitModalActionOption.disabled) return;
const actionCwd = gitCwd;
- const action = gitModalAction;
+ const action = selectedGitModalActionOption.action;
const commitMessage = gitModalCommitMessage.trim();
const includeGeneratedCommitMessage = commitMessage.length === 0;
- setIsGitActionRunning(true);
+ setIsGitModalActionRunning(true);
setGitModalError(null);
setGitActionError(null);
setGitModalResult(null);
@@ -322,11 +418,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
}
if (commitRun.commit.status === "created") {
- updateStep(
- "commit",
- "completed",
- commitRun.commit.subject ?? "Committed local changes.",
- );
+ updateStep("commit", "completed", commitRun.commit.subject ?? "Committed local changes.");
} else {
updateStep("commit", "skipped", "No local changes to commit.");
}
@@ -362,9 +454,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
updateStep(
"pr",
"completed",
- prRun.pr.number
- ? `Opened existing PR #${prRun.pr.number}.`
- : "Opened existing PR.",
+ prRun.pr.number ? `Opened existing PR #${prRun.pr.number}.` : "Opened existing PR.",
);
} else if (prRun.pr.status === "created") {
updateStep(
@@ -388,53 +478,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
return updateProgressStep(steps, active.id, "failed", message);
});
} finally {
- setIsGitActionRunning(false);
- try {
- if (latestGitCwdRef.current === actionCwd) {
- await refreshGitStatus();
- }
- } catch {
- setGitStatus(null);
- }
+ setIsGitModalActionRunning(false);
+ await refreshGitStatus().catch(() => undefined);
}
- }, [api, gitCwd, gitModalAction, gitModalCommitMessage, refreshGitStatus]);
+ }, [
+ api,
+ gitCwd,
+ gitModalCommitMessage,
+ isGitModalOpen,
+ refreshGitStatus,
+ selectedGitModalActionOption,
+ ]);
useEffect(() => {
setGitActionError(null);
setGitModalError(null);
- setGitModalAction(null);
+ setIsGitModalOpen(false);
+ setGitModalSelectedAction("commit");
setGitModalCommitMessage("");
setGitModalProgress([]);
setGitModalResult(null);
}, [gitCwd]);
useEffect(() => {
- let cancelled = false;
- if (!api || !gitCwd) {
- setGitStatus(null);
- return;
+ if (!isGitModalOpen) return;
+ if (selectedGitModalActionOption && !selectedGitModalActionOption.disabled) return;
+
+ const fallback = gitModalActionOptions.find((option) => !option.disabled);
+ if (fallback) {
+ setGitModalSelectedAction(fallback.action);
}
-
- const load = async () => {
- try {
- const nextStatus = await api.git.status({ cwd: gitCwd });
- if (!cancelled) {
- setGitStatus(nextStatus);
- setGitActionError(null);
- }
- } catch (error) {
- if (!cancelled) {
- setGitStatus(null);
- setGitActionError(error instanceof Error ? error.message : "Failed to read git status.");
- }
- }
- };
-
- void load();
- return () => {
- cancelled = true;
- };
- }, [api, gitCwd]);
+ }, [gitModalActionOptions, isGitModalOpen, selectedGitModalActionOption]);
useEffect(() => {
if (!isGitMenuOpen) return;
@@ -492,16 +566,24 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
{gitActionMenuItems.map((item) => {
return (
{
- if (isViewPrOnlyAction(item.id, gitStatus)) {
- void runGitActionImmediately(item.id);
+ if (item.kind === "open_modal") {
+ openGitActionModal();
return;
}
- openGitActionModal(item.id);
+
+ if (item.kind === "open_pr") {
+ openExistingPr();
+ return;
+ }
+
+ if (item.action) {
+ void runGitActionImmediately(item.action);
+ }
}}
>
@@ -523,18 +605,18 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
Branch is behind upstream. Pull/rebase before opening a PR.
)}
- {gitActionError && (
+ {visibleGitActionError && (
- {gitActionError}
+ {visibleGitActionError}
)}
)}
- {isGitModalOpen && gitModalAction && (
+ {isGitModalOpen && (
{
closeGitActionModal();
}}
@@ -552,13 +634,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
@@ -567,7 +643,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
Git actions
- {gitActionModalTitle(gitModalAction)}
+ {gitActionModalTitle()}
@@ -581,7 +657,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
-
Branch
@@ -592,11 +667,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
Changes
- {gitStatus?.hasWorkingTreeChanges ? "Working tree has changes" : "No local changes"}
+ {gitStatus?.hasWorkingTreeChanges
+ ? "Working tree has changes"
+ : "No local changes"}
-
setGitModalCommitMessage(event.target.value)}
- disabled={isGitActionRunning || gitModalResult !== null}
+ disabled={!gitModalSelectionMode || isGitActionRunning}
/>
Leave this empty to use AI-generated commit text.
-
Next steps
- {gitModalSteps.map((step, index) => {
- const borderClass =
- index < gitModalSteps.length - 1 ? "border-b border-border/70" : "";
- const statusTextClass =
- step.status === "failed"
- ? "text-rose-500 dark:text-rose-300"
- : step.status === "completed"
- ? "text-emerald-600 dark:text-emerald-300"
- : "text-muted-foreground/70";
+ {gitModalSelectionMode &&
+ gitModalActionOptions.map((option, index) => {
+ const borderClass =
+ index < gitModalActionOptions.length - 1 ? "border-b border-border/70" : "";
+ const selected = option.action === gitModalSelectedAction && !option.disabled;
- return (
-
-
- {step.status === "running" ? (
-
- ) : step.status === "completed" ? (
-
- ) : step.status === "skipped" ? (
-
- ) : step.status === "failed" ? (
-
- ) : (
-
- )}
-
-
-
{step.label}
- {step.detail && (
-
{step.detail}
- )}
+ return (
+
{
+ setGitModalSelectedAction(option.action);
+ }}
+ >
+
+
+
+
+
{option.label}
+ {option.detail && (
+
+ {option.detail}
+
+ )}
+
+ {selected ? (
+
+ ) : null}
+
+ );
+ })}
+ {!gitModalSelectionMode &&
+ gitModalSteps.map((step, index) => {
+ const borderClass =
+ index < gitModalSteps.length - 1 ? "border-b border-border/70" : "";
+ const statusTextClass =
+ step.status === "failed"
+ ? "text-rose-500 dark:text-rose-300"
+ : step.status === "completed"
+ ? "text-emerald-600 dark:text-emerald-300"
+ : "text-muted-foreground/70";
+
+ return (
+
+
+ {step.status === "running" ? (
+
+ ) : step.status === "completed" ? (
+
+ ) : step.status === "skipped" ? (
+
+ ) : step.status === "failed" ? (
+
+ ) : (
+
+ )}
+
+
+
{step.label}
+ {step.detail && (
+
{step.detail}
+ )}
+
-
- );
- })}
+ );
+ })}
-
- {(gitModalError ?? gitActionError) && (
+ {(gitModalError ?? visibleGitActionError) && (
- {gitModalError ?? gitActionError}
+ {gitModalError ?? visibleGitActionError}
)}
-
{gitModalResult?.pr.url && (
PR:{" "}
@@ -680,7 +789,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
)}
-
{
void runGitAction();
}}
- disabled={isGitActionRunning}
+ disabled={isGitActionRunning || !selectedGitModalActionOption}
>
- {isGitActionRunning ? "Running..." : runActionLabel(gitModalAction)}
+ {isGitActionRunning
+ ? "Running..."
+ : gitModalSelectionMode
+ ? "Continue"
+ : runActionLabel(gitModalSelectedAction)}
)}
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9e18a0ac0f90380721cf3669c08058a77f49f65a
--- /dev/null
+++ b/apps/web/src/components/Icons.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from "react";
+
+export type Icon = React.FC
>;
+
+export const GitHubIcon: Icon = (props) => (
+
+
+
+);
--
2.54.0