authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 14:12:45-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 14:12:45-08:00
log9accd1ffb758431dd80b8c1b79ceb5dc618abaca
treeb5c51262cd16597d5cfd56017823cf91ab768f21
parent62cc55ab0669d8614c08ab29551e71a815006f4a

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 <codex@users.noreply.github.com>

2 files changed, 355 insertions(+), 228 deletions(-)

apps/web/src/components/GitActionsControl.tsx+340-228
...@@ -4,36 +4,44 @@ import {...@@ -4,36 +4,44 @@ import {
4 type GitStatusResult,4 type GitStatusResult,
5 type NativeApi,5 type NativeApi,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
7import { useCallback, useEffect, useMemo, useRef, useState } from "react";8import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8import {9import {
9 CheckIcon,10 CheckIcon,
10 CircleIcon,11 CircleIcon,
11 CloudUploadIcon,12 CloudUploadIcon,
12 GitCommitIcon,13 GitCommitIcon,
13 GithubIcon,
14 Loader2Icon,14 Loader2Icon,
15 MinusIcon,15 MinusIcon,
16 XIcon,16 XIcon,
17} from "lucide-react";17} from "lucide-react";
18import { GitHubIcon } from "./Icons";
1819
19interface GitActionsControlProps {20interface GitActionsControlProps {
20 api: NativeApi | undefined;21 api: NativeApi | undefined;
21 gitCwd: string | null;22 gitCwd: string | null;
22}23}
2324
25type GitActionIconName = "commit" | "push" | "pr";
26
24interface GitActionMenuItem {27interface GitActionMenuItem {
25 id: GitStackedAction;28 id: "commit" | "push" | "pr";
26 label: string;29 label: string;
27 disabled: boolean;30 disabled: boolean;
28 icon: "commit" | "push" | "pr";31 icon: GitActionIconName;
32 kind: "open_modal" | "run_action" | "open_pr";
33 action?: GitStackedAction;
29}34}
3035
31type GitProgressStepStatus =36interface GitModalActionOption {
32 | "pending"37 action: GitStackedAction;
33 | "running"38 label: string;
34 | "completed"39 disabled: boolean;
35 | "skipped"40 icon: GitActionIconName;
36 | "failed";41 detail?: string;
42}
43
44type GitProgressStepStatus = "pending" | "running" | "completed" | "skipped" | "failed";
3745
38interface GitProgressStep {46interface GitProgressStep {
39 id: "generate" | "commit" | "push" | "pr";47 id: "generate" | "commit" | "push" | "pr";
...@@ -42,7 +50,7 @@ interface GitProgressStep {...@@ -42,7 +50,7 @@ interface GitProgressStep {
42 detail?: string;50 detail?: string;
43}51}
4452
45function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boolean }) {53function GitActionIcon(props: { icon: GitActionIconName; disabled: boolean }) {
46 const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85";54 const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85";
4755
48 if (props.icon === "commit") {56 if (props.icon === "commit") {
...@@ -53,13 +61,11 @@ function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boole...@@ -53,13 +61,11 @@ function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boole
53 return <CloudUploadIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;61 return <CloudUploadIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
54 }62 }
5563
56 return <GithubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;64 return <GitHubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
57}65}
5866
59function gitActionModalTitle(action: GitStackedAction): string {67function gitActionModalTitle(): string {
60 if (action === "commit") return "Commit your changes";68 return "Commit your changes";
61 if (action === "commit_push") return "Commit and push changes";
62 return "Commit, push and open PR";
63}69}
6470
65function initialGitProgressSteps(71function initialGitProgressSteps(
...@@ -119,102 +125,198 @@ function updateProgressStep(...@@ -119,102 +125,198 @@ function updateProgressStep(
119}125}
120126
121function runActionLabel(action: GitStackedAction): string {127function runActionLabel(action: GitStackedAction): string {
122 if (action === "commit") return "Commit";128 if (action === "commit") return "Commit changes";
123 if (action === "commit_push") return "Commit & Push";129 if (action === "commit_push") return "Commit and push";
124 return "Commit, Push & Open PR";130 return "Commit and create PR";
125}131}
126132
127function isViewPrOnlyAction(133function buildGitActionMenuItems(
128 action: GitStackedAction,
129 gitStatus: GitStatusResult | null,134 gitStatus: GitStatusResult | null,
130): boolean {135 isDisabled: boolean,
131 if (action !== "commit_push_pr") return false;136): GitActionMenuItem[] {
132 if (!gitStatus?.openPr) return false;137 if (!gitStatus) return [];
133 return !gitStatus.hasWorkingTreeChanges && gitStatus.aheadCount === 0;138
139 const hasBranch = gitStatus.branch !== null;
140 const hasOpenPr = gitStatus.openPr !== null;
141 const hasChanges = gitStatus.hasWorkingTreeChanges;
142 const hasAhead = gitStatus.aheadCount > 0;
143 const canCommit = !isDisabled && hasChanges;
144 const canPush = !isDisabled && hasBranch && !hasChanges && hasAhead;
145 const canOpenPr = !isDisabled && hasOpenPr;
146 const canCreatePr =
147 !isDisabled &&
148 hasBranch &&
149 !hasOpenPr &&
150 !hasChanges &&
151 gitStatus.hasUpstream &&
152 gitStatus.behindCount === 0;
153
154 if (!hasChanges && !hasAhead && hasOpenPr) {
155 return [
156 {
157 id: "pr",
158 label: "Open PR",
159 disabled: !canOpenPr,
160 icon: "pr",
161 kind: "open_pr",
162 },
163 ];
164 }
165
166 return [
167 {
168 id: "commit",
169 label: "Commit",
170 disabled: !canCommit,
171 icon: "commit",
172 kind: "open_modal",
173 },
174 {
175 id: "push",
176 label: "Push",
177 disabled: !canPush,
178 icon: "push",
179 kind: "run_action",
180 action: "commit_push",
181 },
182 hasOpenPr
183 ? {
184 id: "pr",
185 label: "View PR",
186 disabled: !canOpenPr,
187 icon: "pr",
188 kind: "open_pr",
189 }
190 : {
191 id: "pr",
192 label: "Create PR",
193 disabled: !canCreatePr,
194 icon: "pr",
195 kind: "run_action",
196 action: "commit_push_pr",
197 },
198 ];
199}
200
201function buildGitModalActionOptions(
202 gitStatus: GitStatusResult | null,
203 isDisabled: boolean,
204): GitModalActionOption[] {
205 const hasBranch = gitStatus?.branch !== null;
206 const hasOpenPr = gitStatus?.openPr !== null;
207
208 return [
209 {
210 action: "commit",
211 label: "Commit",
212 disabled: isDisabled,
213 icon: "commit",
214 },
215 {
216 action: "commit_push",
217 label: "Commit and push",
218 disabled: isDisabled || !hasBranch,
219 icon: "push",
220 ...(!hasBranch ? { detail: "Requires an attached branch." } : {}),
221 },
222 {
223 action: "commit_push_pr",
224 label: "Commit and create PR",
225 disabled: isDisabled || !hasBranch || hasOpenPr,
226 icon: "pr",
227 ...(!hasBranch
228 ? { detail: "Requires an attached branch." }
229 : hasOpenPr
230 ? { detail: "A PR is already open for this branch." }
231 : {}),
232 },
233 ];
234}
235
236function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] {
237 return ["git", "status", gitCwd];
238}
239
240function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null) {
241 return queryOptions({
242 queryKey: getGitStatusQueryKey(gitCwd),
243 queryFn: async () => {
244 if (!api || !gitCwd) {
245 throw new Error("Git status is unavailable.");
246 }
247 return api.git.status({ cwd: gitCwd });
248 },
249 enabled: !!api && !!gitCwd,
250 });
134}251}
135252
136export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {253export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
254 const queryClient = useQueryClient();
137 const [isGitMenuOpen, setIsGitMenuOpen] = useState(false);255 const [isGitMenuOpen, setIsGitMenuOpen] = useState(false);
138 const [isGitActionRunning, setIsGitActionRunning] = useState(false);256 const [isGitModalActionRunning, setIsGitModalActionRunning] = useState(false);
139 const [gitStatus, setGitStatus] = useState<GitStatusResult | null>(null);
140 const [gitActionError, setGitActionError] = useState<string | null>(null);257 const [gitActionError, setGitActionError] = useState<string | null>(null);
141 const [gitModalAction, setGitModalAction] = useState<GitStackedAction | null>(null);258 const [isGitModalOpen, setIsGitModalOpen] = useState(false);
259 const [gitModalSelectedAction, setGitModalSelectedAction] = useState<GitStackedAction>("commit");
142 const [gitModalCommitMessage, setGitModalCommitMessage] = useState("");260 const [gitModalCommitMessage, setGitModalCommitMessage] = useState("");
143 const [gitModalProgress, setGitModalProgress] = useState<GitProgressStep[]>([]);261 const [gitModalProgress, setGitModalProgress] = useState<GitProgressStep[]>([]);
144 const [gitModalError, setGitModalError] = useState<string | null>(null);262 const [gitModalError, setGitModalError] = useState<string | null>(null);
145 const [gitModalResult, setGitModalResult] = useState<GitRunStackedActionResult | null>(null);263 const [gitModalResult, setGitModalResult] = useState<GitRunStackedActionResult | null>(null);
146 const gitMenuRef = useRef<HTMLDivElement>(null);264 const gitMenuRef = useRef<HTMLDivElement>(null);
147 const latestGitCwdRef = useRef<string | null>(null);
148
149 const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning;
150 const gitActionMenuItems = useMemo<GitActionMenuItem[]>(() => {
151 if (!gitStatus) return [];
152
153 const hasBranch = gitStatus.branch !== null;
154 const hasOpenPr = gitStatus.openPr !== null;
155 const canCommit = !gitBaseDisabled && gitStatus.hasWorkingTreeChanges;
156 const canPush =
157 !gitBaseDisabled &&
158 hasBranch &&
159 (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0);
160 const canViewPr =
161 !gitBaseDisabled &&
162 hasBranch &&
163 gitStatus.behindCount === 0 &&
164 (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0 || hasOpenPr);
165265
166 return [266 const { data: gitStatus = null, error: gitStatusError } = useQuery(
167 {267 gitStatusQueryOptions(api, gitCwd),
168 id: "commit",
169 label: "Commit",
170 disabled: !canCommit,
171 icon: "commit",
172 },
173 {
174 id: "commit_push",
175 label: "Push",
176 disabled: !canPush,
177 icon: "push",
178 },
179 {
180 id: "commit_push_pr",
181 label: "View PR",
182 disabled: !canViewPr,
183 icon: "pr",
184 },
185 ];
186 }, [gitBaseDisabled, gitStatus]);
187
188 const isGitModalOpen = gitModalAction !== null;
189 const gitModalPreviewSteps = useMemo(
190 () =>
191 gitModalAction
192 ? initialGitProgressSteps(gitModalAction, gitModalCommitMessage)
193 : ([] as GitProgressStep[]),
194 [gitModalAction, gitModalCommitMessage],
195 );268 );
196 const gitModalSteps = gitModalProgress.length > 0 ? gitModalProgress : gitModalPreviewSteps;
197269
198 useEffect(() => {270 const runImmediateGitActionMutation = useMutation({
199 latestGitCwdRef.current = gitCwd;271 mutationFn: async (action: GitStackedAction) => {
200 }, [gitCwd]);272 if (!api || !gitCwd) {
273 throw new Error("Git action is unavailable.");
274 }
275 return api.git.runStackedAction({ cwd: gitCwd, action });
276 },
277 onMutate: () => {
278 setIsGitMenuOpen(false);
279 setGitActionError(null);
280 setGitModalError(null);
281 },
282 onSuccess: async () => {
283 await queryClient.invalidateQueries({ queryKey: ["git"] });
284 },
285 onError: (error) => {
286 setGitActionError(error instanceof Error ? error.message : "Git action failed.");
287 },
288 });
201289
202 const refreshGitStatus = useCallback(async () => {290 const isGitActionRunning = isGitModalActionRunning || runImmediateGitActionMutation.isPending;
203 const requestCwd = gitCwd;
204 if (!api || !requestCwd) {
205 setGitStatus(null);
206 return;
207 }
208291
209 const nextStatus = await api.git.status({ cwd: requestCwd });292 const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning;
210 if (latestGitCwdRef.current !== requestCwd) return;293 const gitActionMenuItems = useMemo(
211 setGitStatus(nextStatus);294 () => buildGitActionMenuItems(gitStatus, gitBaseDisabled),
295 [gitBaseDisabled, gitStatus],
296 );
297 const gitModalActionOptions = useMemo(
298 () => buildGitModalActionOptions(gitStatus, !gitStatus || isGitActionRunning),
299 [gitStatus, isGitActionRunning],
300 );
301 const selectedGitModalActionOption = useMemo(
302 () => gitModalActionOptions.find((option) => option.action === gitModalSelectedAction) ?? null,
303 [gitModalActionOptions, gitModalSelectedAction],
304 );
305 const visibleGitActionError = gitActionError ?? gitStatusError?.message;
306 const gitModalHasProgress = gitModalProgress.length > 0;
307 const gitModalSelectionMode = !gitModalHasProgress && gitModalResult === null;
308 const gitModalSteps = gitModalProgress;
309
310 const refreshGitStatus = useCallback(async () => {
311 if (!api || !gitCwd) return;
312 await queryClient.invalidateQueries({ queryKey: ["git"] });
212 setGitActionError(null);313 setGitActionError(null);
213 }, [api, gitCwd]);314 }, [api, gitCwd, queryClient]);
214315
215 const openGitActionModal = useCallback((action: GitStackedAction) => {316 const openGitActionModal = useCallback(() => {
216 setIsGitMenuOpen(false);317 setIsGitMenuOpen(false);
217 setGitModalAction(action);318 setIsGitModalOpen(true);
319 setGitModalSelectedAction("commit");
218 setGitModalCommitMessage("");320 setGitModalCommitMessage("");
219 setGitModalProgress([]);321 setGitModalProgress([]);
220 setGitModalError(null);322 setGitModalError(null);
...@@ -224,7 +326,8 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -224,7 +326,8 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
224326
225 const closeGitActionModal = useCallback(() => {327 const closeGitActionModal = useCallback(() => {
226 if (isGitActionRunning) return;328 if (isGitActionRunning) return;
227 setGitModalAction(null);329 setIsGitModalOpen(false);
330 setGitModalSelectedAction("commit");
228 setGitModalCommitMessage("");331 setGitModalCommitMessage("");
229 setGitModalProgress([]);332 setGitModalProgress([]);
230 setGitModalError(null);333 setGitModalError(null);
...@@ -232,44 +335,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -232,44 +335,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
232 }, [isGitActionRunning]);335 }, [isGitActionRunning]);
233336
234 const runGitActionImmediately = useCallback(337 const runGitActionImmediately = useCallback(
235 async (action: GitStackedAction) => {338 (action: GitStackedAction) => {
236 if (!api || !gitCwd) return;339 runImmediateGitActionMutation.mutate(action);
237 const actionCwd = gitCwd;
238
239 setIsGitMenuOpen(false);
240 setGitActionError(null);
241 setGitModalError(null);
242 setIsGitActionRunning(true);
243
244 try {
245 await api.git.runStackedAction({
246 cwd: actionCwd,
247 action,
248 });
249 } catch (error) {
250 setGitActionError(error instanceof Error ? error.message : "Git action failed.");
251 } finally {
252 setIsGitActionRunning(false);
253 try {
254 if (latestGitCwdRef.current === actionCwd) {
255 await refreshGitStatus();
256 }
257 } catch {
258 setGitStatus(null);
259 }
260 }
261 },340 },
262 [api, gitCwd, refreshGitStatus],341 [runImmediateGitActionMutation],
263 );342 );
264343
344 const openExistingPr = useCallback(() => {
345 setIsGitMenuOpen(false);
346 setGitActionError(null);
347
348 const prUrl = gitStatus?.openPr?.url ?? null;
349 if (!prUrl) {
350 setGitActionError("No open PR found for the current branch.");
351 return;
352 }
353
354 const popup = window.open(prUrl, "_blank", "noopener,noreferrer");
355 if (!popup) {
356 setGitActionError("Unable to open PR. Allow popups and try again.");
357 }
358 }, [gitStatus?.openPr?.url]);
359
265 const runGitAction = useCallback(async () => {360 const runGitAction = useCallback(async () => {
266 if (!api || !gitCwd || !gitModalAction) return;361 if (!api || !gitCwd || !isGitModalOpen) return;
362 if (!selectedGitModalActionOption || selectedGitModalActionOption.disabled) return;
267 const actionCwd = gitCwd;363 const actionCwd = gitCwd;
268 const action = gitModalAction;364 const action = selectedGitModalActionOption.action;
269 const commitMessage = gitModalCommitMessage.trim();365 const commitMessage = gitModalCommitMessage.trim();
270 const includeGeneratedCommitMessage = commitMessage.length === 0;366 const includeGeneratedCommitMessage = commitMessage.length === 0;
271367
272 setIsGitActionRunning(true);368 setIsGitModalActionRunning(true);
273 setGitModalError(null);369 setGitModalError(null);
274 setGitActionError(null);370 setGitActionError(null);
275 setGitModalResult(null);371 setGitModalResult(null);
...@@ -322,11 +418,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -322,11 +418,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
322 }418 }
323419
324 if (commitRun.commit.status === "created") {420 if (commitRun.commit.status === "created") {
325 updateStep(421 updateStep("commit", "completed", commitRun.commit.subject ?? "Committed local changes.");
326 "commit",
327 "completed",
328 commitRun.commit.subject ?? "Committed local changes.",
329 );
330 } else {422 } else {
331 updateStep("commit", "skipped", "No local changes to commit.");423 updateStep("commit", "skipped", "No local changes to commit.");
332 }424 }
...@@ -362,9 +454,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -362,9 +454,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
362 updateStep(454 updateStep(
363 "pr",455 "pr",
364 "completed",456 "completed",
365 prRun.pr.number457 prRun.pr.number ? `Opened existing PR #${prRun.pr.number}.` : "Opened existing PR.",
366 ? `Opened existing PR #${prRun.pr.number}.`
367 : "Opened existing PR.",
368 );458 );
369 } else if (prRun.pr.status === "created") {459 } else if (prRun.pr.status === "created") {
370 updateStep(460 updateStep(
...@@ -388,53 +478,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -388,53 +478,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
388 return updateProgressStep(steps, active.id, "failed", message);478 return updateProgressStep(steps, active.id, "failed", message);
389 });479 });
390 } finally {480 } finally {
391 setIsGitActionRunning(false);481 setIsGitModalActionRunning(false);
392 try {482 await refreshGitStatus().catch(() => undefined);
393 if (latestGitCwdRef.current === actionCwd) {
394 await refreshGitStatus();
395 }
396 } catch {
397 setGitStatus(null);
398 }
399 }483 }
400 }, [api, gitCwd, gitModalAction, gitModalCommitMessage, refreshGitStatus]);484 }, [
485 api,
486 gitCwd,
487 gitModalCommitMessage,
488 isGitModalOpen,
489 refreshGitStatus,
490 selectedGitModalActionOption,
491 ]);
401492
402 useEffect(() => {493 useEffect(() => {
403 setGitActionError(null);494 setGitActionError(null);
404 setGitModalError(null);495 setGitModalError(null);
405 setGitModalAction(null);496 setIsGitModalOpen(false);
497 setGitModalSelectedAction("commit");
406 setGitModalCommitMessage("");498 setGitModalCommitMessage("");
407 setGitModalProgress([]);499 setGitModalProgress([]);
408 setGitModalResult(null);500 setGitModalResult(null);
409 }, [gitCwd]);501 }, [gitCwd]);
410502
411 useEffect(() => {503 useEffect(() => {
412 let cancelled = false;504 if (!isGitModalOpen) return;
413 if (!api || !gitCwd) {505 if (selectedGitModalActionOption && !selectedGitModalActionOption.disabled) return;
414 setGitStatus(null);
415 return;
416 }
417
418 const load = async () => {
419 try {
420 const nextStatus = await api.git.status({ cwd: gitCwd });
421 if (!cancelled) {
422 setGitStatus(nextStatus);
423 setGitActionError(null);
424 }
425 } catch (error) {
426 if (!cancelled) {
427 setGitStatus(null);
428 setGitActionError(error instanceof Error ? error.message : "Failed to read git status.");
429 }
430 }
431 };
432506
433 void load();507 const fallback = gitModalActionOptions.find((option) => !option.disabled);
434 return () => {508 if (fallback) {
435 cancelled = true;509 setGitModalSelectedAction(fallback.action);
436 };510 }
437 }, [api, gitCwd]);511 }, [gitModalActionOptions, isGitModalOpen, selectedGitModalActionOption]);
438512
439 useEffect(() => {513 useEffect(() => {
440 if (!isGitMenuOpen) return;514 if (!isGitMenuOpen) return;
...@@ -492,16 +566,24 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -492,16 +566,24 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
492 {gitActionMenuItems.map((item) => {566 {gitActionMenuItems.map((item) => {
493 return (567 return (
494 <button568 <button
495 key={item.id}569 key={`${item.id}-${item.label}`}
496 type="button"570 type="button"
497 className="mb-1.5 flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-[14px] text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:text-muted-foreground/65"571 className="mb-1.5 flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-[14px] text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:text-muted-foreground/65"
498 disabled={item.disabled}572 disabled={item.disabled}
499 onClick={() => {573 onClick={() => {
500 if (isViewPrOnlyAction(item.id, gitStatus)) {574 if (item.kind === "open_modal") {
501 void runGitActionImmediately(item.id);575 openGitActionModal();
576 return;
577 }
578
579 if (item.kind === "open_pr") {
580 openExistingPr();
502 return;581 return;
503 }582 }
504 openGitActionModal(item.id);583
584 if (item.action) {
585 void runGitActionImmediately(item.action);
586 }
505 }}587 }}
506 >588 >
507 <GitActionIcon icon={item.icon} disabled={item.disabled} />589 <GitActionIcon icon={item.icon} disabled={item.disabled} />
...@@ -523,18 +605,18 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -523,18 +605,18 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
523 Branch is behind upstream. Pull/rebase before opening a PR.605 Branch is behind upstream. Pull/rebase before opening a PR.
524 </p>606 </p>
525 )}607 )}
526 {gitActionError && (608 {visibleGitActionError && (
527 <p className="px-3 pt-2 text-[11px] text-rose-500 dark:text-rose-300">609 <p className="px-3 pt-2 text-[11px] text-rose-500 dark:text-rose-300">
528 {gitActionError}610 {visibleGitActionError}
529 </p>611 </p>
530 )}612 )}
531 </div>613 </div>
532 )}614 )}
533 </div>615 </div>
534616
535 {isGitModalOpen && gitModalAction && (617 {isGitModalOpen && (
536 <div618 <div
537 className="fixed inset-0 z-[80] flex items-center justify-center bg-black/35 px-4 py-6"619 className="fixed inset-0 z-80 flex items-center justify-center bg-black/35 px-4 py-6"
538 onMouseDown={() => {620 onMouseDown={() => {
539 closeGitActionModal();621 closeGitActionModal();
540 }}622 }}
...@@ -552,13 +634,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -552,13 +634,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
552 <div className="flex items-center gap-3">634 <div className="flex items-center gap-3">
553 <div className="rounded-2xl bg-accent p-3">635 <div className="rounded-2xl bg-accent p-3">
554 <GitActionIcon636 <GitActionIcon
555 icon={637 icon={selectedGitModalActionOption?.icon ?? "commit"}
556 gitModalAction === "commit"
557 ? "commit"
558 : gitModalAction === "commit_push"
559 ? "push"
560 : "pr"
561 }
562 disabled={false}638 disabled={false}
563 />639 />
564 </div>640 </div>
...@@ -567,7 +643,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -567,7 +643,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
567 Git actions643 Git actions
568 </p>644 </p>
569 <h3 className="text-3xl font-semibold tracking-tight text-foreground">645 <h3 className="text-3xl font-semibold tracking-tight text-foreground">
570 {gitActionModalTitle(gitModalAction)}646 {gitActionModalTitle()}
571 </h3>647 </h3>
572 </div>648 </div>
573 </div>649 </div>
...@@ -581,7 +657,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -581,7 +657,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
581 <XIcon className="h-5 w-5" />657 <XIcon className="h-5 w-5" />
582 </button>658 </button>
583 </div>659 </div>
584
585 <div className="mt-6 space-y-2 rounded-2xl border border-border/80 bg-card/40 px-4 py-3">660 <div className="mt-6 space-y-2 rounded-2xl border border-border/80 bg-card/40 px-4 py-3">
586 <div className="flex items-center justify-between gap-4 text-sm">661 <div className="flex items-center justify-between gap-4 text-sm">
587 <span className="text-muted-foreground/70">Branch</span>662 <span className="text-muted-foreground/70">Branch</span>
...@@ -592,11 +667,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -592,11 +667,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
592 <div className="flex items-center justify-between gap-4 text-sm">667 <div className="flex items-center justify-between gap-4 text-sm">
593 <span className="text-muted-foreground/70">Changes</span>668 <span className="text-muted-foreground/70">Changes</span>
594 <span className="text-foreground">669 <span className="text-foreground">
595 {gitStatus?.hasWorkingTreeChanges ? "Working tree has changes" : "No local changes"}670 {gitStatus?.hasWorkingTreeChanges
671 ? "Working tree has changes"
672 : "No local changes"}
596 </span>673 </span>
597 </div>674 </div>
598 </div>675 </div>
599
600 <div className="mt-6">676 <div className="mt-6">
601 <label677 <label
602 htmlFor="git-commit-message"678 htmlFor="git-commit-message"
...@@ -611,62 +687,95 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -611,62 +687,95 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
611 placeholder="Leave blank to autogenerate a commit message"687 placeholder="Leave blank to autogenerate a commit message"
612 value={gitModalCommitMessage}688 value={gitModalCommitMessage}
613 onChange={(event) => setGitModalCommitMessage(event.target.value)}689 onChange={(event) => setGitModalCommitMessage(event.target.value)}
614 disabled={isGitActionRunning || gitModalResult !== null}690 disabled={!gitModalSelectionMode || isGitActionRunning}
615 />691 />
616 <p className="mt-1.5 text-xs text-muted-foreground/65">692 <p className="mt-1.5 text-xs text-muted-foreground/65">
617 Leave this empty to use AI-generated commit text.693 Leave this empty to use AI-generated commit text.
618 </p>694 </p>
619 </div>695 </div>
620
621 <div className="mt-6">696 <div className="mt-6">
622 <p className="text-sm font-medium text-foreground">Next steps</p>697 <p className="text-sm font-medium text-foreground">Next steps</p>
623 <div className="mt-2 overflow-hidden rounded-2xl border border-border">698 <div className="mt-2 overflow-hidden rounded-2xl border border-border">
624 {gitModalSteps.map((step, index) => {699 {gitModalSelectionMode &&
625 const borderClass =700 gitModalActionOptions.map((option, index) => {
626 index < gitModalSteps.length - 1 ? "border-b border-border/70" : "";701 const borderClass =
627 const statusTextClass =702 index < gitModalActionOptions.length - 1 ? "border-b border-border/70" : "";
628 step.status === "failed"703 const selected = option.action === gitModalSelectedAction && !option.disabled;
629 ? "text-rose-500 dark:text-rose-300"704
630 : step.status === "completed"705 return (
631 ? "text-emerald-600 dark:text-emerald-300"706 <button
632 : "text-muted-foreground/70";707 key={option.action}
633708 type="button"
634 return (709 className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors duration-150 ${borderClass} ${
635 <div710 selected ? "bg-accent/55" : "bg-card/45 hover:bg-accent/35"
636 key={step.id}711 } disabled:cursor-not-allowed disabled:bg-card/45 disabled:text-muted-foreground/65`}
637 className={`flex items-start gap-3 bg-card/45 px-4 py-3 ${borderClass}`}712 disabled={option.disabled || isGitActionRunning}
638 >713 onClick={() => {
639 <span className="mt-0.5">714 setGitModalSelectedAction(option.action);
640 {step.status === "running" ? (715 }}
641 <Loader2Icon className="h-4 w-4 animate-spin text-foreground" />716 >
642 ) : step.status === "completed" ? (717 <span className="mt-0.5">
643 <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />718 <GitActionIcon icon={option.icon} disabled={option.disabled} />
644 ) : step.status === "skipped" ? (719 </span>
645 <MinusIcon className="h-4 w-4 text-muted-foreground/70" />720 <div className="min-w-0 flex-1">
646 ) : step.status === "failed" ? (721 <p className="text-sm text-foreground">{option.label}</p>
647 <XIcon className="h-4 w-4 text-rose-500 dark:text-rose-300" />722 {option.detail && (
648 ) : (723 <p className="mt-0.5 text-xs text-muted-foreground/70">
649 <CircleIcon className="h-4 w-4 text-muted-foreground/60" />724 {option.detail}
650 )}725 </p>
651 </span>726 )}
652 <div className="min-w-0">727 </div>
653 <p className="text-sm text-foreground">{step.label}</p>728 {selected ? (
654 {step.detail && (729 <CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-foreground" />
655 <p className={`mt-0.5 text-xs ${statusTextClass}`}>{step.detail}</p>730 ) : null}
656 )}731 </button>
732 );
733 })}
734 {!gitModalSelectionMode &&
735 gitModalSteps.map((step, index) => {
736 const borderClass =
737 index < gitModalSteps.length - 1 ? "border-b border-border/70" : "";
738 const statusTextClass =
739 step.status === "failed"
740 ? "text-rose-500 dark:text-rose-300"
741 : step.status === "completed"
742 ? "text-emerald-600 dark:text-emerald-300"
743 : "text-muted-foreground/70";
744
745 return (
746 <div
747 key={step.id}
748 className={`flex items-start gap-3 bg-card/45 px-4 py-3 ${borderClass}`}
749 >
750 <span className="mt-0.5">
751 {step.status === "running" ? (
752 <Loader2Icon className="h-4 w-4 animate-spin text-foreground" />
753 ) : step.status === "completed" ? (
754 <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
755 ) : step.status === "skipped" ? (
756 <MinusIcon className="h-4 w-4 text-muted-foreground/70" />
757 ) : step.status === "failed" ? (
758 <XIcon className="h-4 w-4 text-rose-500 dark:text-rose-300" />
759 ) : (
760 <CircleIcon className="h-4 w-4 text-muted-foreground/60" />
761 )}
762 </span>
763 <div className="min-w-0">
764 <p className="text-sm text-foreground">{step.label}</p>
765 {step.detail && (
766 <p className={`mt-0.5 text-xs ${statusTextClass}`}>{step.detail}</p>
767 )}
768 </div>
657 </div>769 </div>
658 </div>770 );
659 );771 })}
660 })}
661 </div>772 </div>
662 </div>773 </div>
663774 {(gitModalError ?? visibleGitActionError) && (
664 {(gitModalError ?? gitActionError) && (
665 <div className="mt-4 rounded-lg border border-rose-300/50 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-200">775 <div className="mt-4 rounded-lg border border-rose-300/50 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-200">
666 {gitModalError ?? gitActionError}776 {gitModalError ?? visibleGitActionError}
667 </div>777 </div>
668 )}778 )}
669
670 {gitModalResult?.pr.url && (779 {gitModalResult?.pr.url && (
671 <div className="mt-4 rounded-lg border border-border bg-card/40 px-3 py-2 text-xs text-muted-foreground/80">780 <div className="mt-4 rounded-lg border border-border bg-card/40 px-3 py-2 text-xs text-muted-foreground/80">
672 PR:{" "}781 PR:{" "}
...@@ -680,7 +789,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -680,7 +789,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
680 </a>789 </a>
681 </div>790 </div>
682 )}791 )}
683
684 <div className="mt-6 flex justify-end gap-2">792 <div className="mt-6 flex justify-end gap-2">
685 <button793 <button
686 type="button"794 type="button"
...@@ -697,9 +805,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -697,9 +805,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
697 onClick={() => {805 onClick={() => {
698 void runGitAction();806 void runGitAction();
699 }}807 }}
700 disabled={isGitActionRunning}808 disabled={isGitActionRunning || !selectedGitModalActionOption}
701 >809 >
702 {isGitActionRunning ? "Running..." : runActionLabel(gitModalAction)}810 {isGitActionRunning
811 ? "Running..."
812 : gitModalSelectionMode
813 ? "Continue"
814 : runActionLabel(gitModalSelectedAction)}
703 </button>815 </button>
704 )}816 )}
705 </div>817 </div>
apps/web/src/components/Icons.tsx created+15
...@@ -0,0 +1,15 @@
1import type { SVGProps } from "react";
2
3export type Icon = React.FC<SVGProps<SVGSVGElement>>;
4
5export const GitHubIcon: Icon = (props) => (
6 <svg {...props} viewBox="0 0 1024 1024" fill="none">
7 <path
8 fillRule="evenodd"
9 clipRule="evenodd"
10 d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z"
11 transform="scale(64)"
12 fill="#1B1F23"
13 />
14 </svg>
15);