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 {
44 type GitStatusResult,
55 type NativeApi,
66} from "@t3tools/contracts";
7import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
78import { useCallback, useEffect, useMemo, useRef, useState } from "react";
89import {
910 CheckIcon,
1011 CircleIcon,
1112 CloudUploadIcon,
1213 GitCommitIcon,
13 GithubIcon,
1414 Loader2Icon,
1515 MinusIcon,
1616 XIcon,
1717} from "lucide-react";
18import { GitHubIcon } from "./Icons";
1819
1920interface GitActionsControlProps {
2021 api: NativeApi | undefined;
2122 gitCwd: string | null;
2223}
2324
25type GitActionIconName = "commit" | "push" | "pr";
26
2427interface GitActionMenuItem {
25 id: GitStackedAction;
28 id: "commit" | "push" | "pr";
2629 label: string;
2730 disabled: boolean;
28 icon: "commit" | "push" | "pr";
31 icon: GitActionIconName;
32 kind: "open_modal" | "run_action" | "open_pr";
33 action?: GitStackedAction;
2934}
3035
31type GitProgressStepStatus =
32 | "pending"
33 | "running"
34 | "completed"
35 | "skipped"
36 | "failed";
36interface GitModalActionOption {
37 action: GitStackedAction;
38 label: string;
39 disabled: boolean;
40 icon: GitActionIconName;
41 detail?: string;
42}
43
44type GitProgressStepStatus = "pending" | "running" | "completed" | "skipped" | "failed";
3745
3846interface GitProgressStep {
3947 id: "generate" | "commit" | "push" | "pr";
......@@ -42,7 +50,7 @@ interface GitProgressStep {
4250 detail?: string;
4351}
4452
45function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boolean }) {
53function GitActionIcon(props: { icon: GitActionIconName; disabled: boolean }) {
4654 const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85";
4755
4856 if (props.icon === "commit") {
......@@ -53,13 +61,11 @@ function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boole
5361 return <CloudUploadIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
5462 }
5563
56 return <GithubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
64 return <GitHubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
5765}
5866
59function gitActionModalTitle(action: GitStackedAction): string {
60 if (action === "commit") return "Commit your changes";
61 if (action === "commit_push") return "Commit and push changes";
62 return "Commit, push and open PR";
67function gitActionModalTitle(): string {
68 return "Commit your changes";
6369}
6470
6571function initialGitProgressSteps(
......@@ -119,102 +125,198 @@ function updateProgressStep(
119125}
120126
121127function runActionLabel(action: GitStackedAction): string {
122 if (action === "commit") return "Commit";
123 if (action === "commit_push") return "Commit & Push";
124 return "Commit, Push & Open PR";
128 if (action === "commit") return "Commit changes";
129 if (action === "commit_push") return "Commit and push";
130 return "Commit and create PR";
125131}
126132
127function isViewPrOnlyAction(
128 action: GitStackedAction,
133function buildGitActionMenuItems(
129134 gitStatus: GitStatusResult | null,
130): boolean {
131 if (action !== "commit_push_pr") return false;
132 if (!gitStatus?.openPr) return false;
133 return !gitStatus.hasWorkingTreeChanges && gitStatus.aheadCount === 0;
135 isDisabled: boolean,
136): GitActionMenuItem[] {
137 if (!gitStatus) return [];
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 });
134251}
135252
136253export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
254 const queryClient = useQueryClient();
137255 const [isGitMenuOpen, setIsGitMenuOpen] = useState(false);
138 const [isGitActionRunning, setIsGitActionRunning] = useState(false);
139 const [gitStatus, setGitStatus] = useState<GitStatusResult | null>(null);
256 const [isGitModalActionRunning, setIsGitModalActionRunning] = useState(false);
140257 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");
142260 const [gitModalCommitMessage, setGitModalCommitMessage] = useState("");
143261 const [gitModalProgress, setGitModalProgress] = useState<GitProgressStep[]>([]);
144262 const [gitModalError, setGitModalError] = useState<string | null>(null);
145263 const [gitModalResult, setGitModalResult] = useState<GitRunStackedActionResult | null>(null);
146264 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 [
167 {
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],
266 const { data: gitStatus = null, error: gitStatusError } = useQuery(
267 gitStatusQueryOptions(api, gitCwd),
195268 );
196 const gitModalSteps = gitModalProgress.length > 0 ? gitModalProgress : gitModalPreviewSteps;
197269
198 useEffect(() => {
199 latestGitCwdRef.current = gitCwd;
200 }, [gitCwd]);
270 const runImmediateGitActionMutation = useMutation({
271 mutationFn: async (action: GitStackedAction) => {
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 () => {
203 const requestCwd = gitCwd;
204 if (!api || !requestCwd) {
205 setGitStatus(null);
206 return;
207 }
290 const isGitActionRunning = isGitModalActionRunning || runImmediateGitActionMutation.isPending;
208291
209 const nextStatus = await api.git.status({ cwd: requestCwd });
210 if (latestGitCwdRef.current !== requestCwd) return;
211 setGitStatus(nextStatus);
292 const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning;
293 const gitActionMenuItems = useMemo(
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"] });
212313 setGitActionError(null);
213 }, [api, gitCwd]);
314 }, [api, gitCwd, queryClient]);
214315
215 const openGitActionModal = useCallback((action: GitStackedAction) => {
316 const openGitActionModal = useCallback(() => {
216317 setIsGitMenuOpen(false);
217 setGitModalAction(action);
318 setIsGitModalOpen(true);
319 setGitModalSelectedAction("commit");
218320 setGitModalCommitMessage("");
219321 setGitModalProgress([]);
220322 setGitModalError(null);
......@@ -224,7 +326,8 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
224326
225327 const closeGitActionModal = useCallback(() => {
226328 if (isGitActionRunning) return;
227 setGitModalAction(null);
329 setIsGitModalOpen(false);
330 setGitModalSelectedAction("commit");
228331 setGitModalCommitMessage("");
229332 setGitModalProgress([]);
230333 setGitModalError(null);
......@@ -232,44 +335,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
232335 }, [isGitActionRunning]);
233336
234337 const runGitActionImmediately = useCallback(
235 async (action: GitStackedAction) => {
236 if (!api || !gitCwd) return;
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 }
338 (action: GitStackedAction) => {
339 runImmediateGitActionMutation.mutate(action);
261340 },
262 [api, gitCwd, refreshGitStatus],
341 [runImmediateGitActionMutation],
263342 );
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
265360 const runGitAction = useCallback(async () => {
266 if (!api || !gitCwd || !gitModalAction) return;
361 if (!api || !gitCwd || !isGitModalOpen) return;
362 if (!selectedGitModalActionOption || selectedGitModalActionOption.disabled) return;
267363 const actionCwd = gitCwd;
268 const action = gitModalAction;
364 const action = selectedGitModalActionOption.action;
269365 const commitMessage = gitModalCommitMessage.trim();
270366 const includeGeneratedCommitMessage = commitMessage.length === 0;
271367
272 setIsGitActionRunning(true);
368 setIsGitModalActionRunning(true);
273369 setGitModalError(null);
274370 setGitActionError(null);
275371 setGitModalResult(null);
......@@ -322,11 +418,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
322418 }
323419
324420 if (commitRun.commit.status === "created") {
325 updateStep(
326 "commit",
327 "completed",
328 commitRun.commit.subject ?? "Committed local changes.",
329 );
421 updateStep("commit", "completed", commitRun.commit.subject ?? "Committed local changes.");
330422 } else {
331423 updateStep("commit", "skipped", "No local changes to commit.");
332424 }
......@@ -362,9 +454,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
362454 updateStep(
363455 "pr",
364456 "completed",
365 prRun.pr.number
366 ? `Opened existing PR #${prRun.pr.number}.`
367 : "Opened existing PR.",
457 prRun.pr.number ? `Opened existing PR #${prRun.pr.number}.` : "Opened existing PR.",
368458 );
369459 } else if (prRun.pr.status === "created") {
370460 updateStep(
......@@ -388,53 +478,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
388478 return updateProgressStep(steps, active.id, "failed", message);
389479 });
390480 } finally {
391 setIsGitActionRunning(false);
392 try {
393 if (latestGitCwdRef.current === actionCwd) {
394 await refreshGitStatus();
395 }
396 } catch {
397 setGitStatus(null);
398 }
481 setIsGitModalActionRunning(false);
482 await refreshGitStatus().catch(() => undefined);
399483 }
400 }, [api, gitCwd, gitModalAction, gitModalCommitMessage, refreshGitStatus]);
484 }, [
485 api,
486 gitCwd,
487 gitModalCommitMessage,
488 isGitModalOpen,
489 refreshGitStatus,
490 selectedGitModalActionOption,
491 ]);
401492
402493 useEffect(() => {
403494 setGitActionError(null);
404495 setGitModalError(null);
405 setGitModalAction(null);
496 setIsGitModalOpen(false);
497 setGitModalSelectedAction("commit");
406498 setGitModalCommitMessage("");
407499 setGitModalProgress([]);
408500 setGitModalResult(null);
409501 }, [gitCwd]);
410502
411503 useEffect(() => {
412 let cancelled = false;
413 if (!api || !gitCwd) {
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 };
504 if (!isGitModalOpen) return;
505 if (selectedGitModalActionOption && !selectedGitModalActionOption.disabled) return;
432506
433 void load();
434 return () => {
435 cancelled = true;
436 };
437 }, [api, gitCwd]);
507 const fallback = gitModalActionOptions.find((option) => !option.disabled);
508 if (fallback) {
509 setGitModalSelectedAction(fallback.action);
510 }
511 }, [gitModalActionOptions, isGitModalOpen, selectedGitModalActionOption]);
438512
439513 useEffect(() => {
440514 if (!isGitMenuOpen) return;
......@@ -492,16 +566,24 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
492566 {gitActionMenuItems.map((item) => {
493567 return (
494568 <button
495 key={item.id}
569 key={`${item.id}-${item.label}`}
496570 type="button"
497571 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"
498572 disabled={item.disabled}
499573 onClick={() => {
500 if (isViewPrOnlyAction(item.id, gitStatus)) {
501 void runGitActionImmediately(item.id);
574 if (item.kind === "open_modal") {
575 openGitActionModal();
576 return;
577 }
578
579 if (item.kind === "open_pr") {
580 openExistingPr();
502581 return;
503582 }
504 openGitActionModal(item.id);
583
584 if (item.action) {
585 void runGitActionImmediately(item.action);
586 }
505587 }}
506588 >
507589 <GitActionIcon icon={item.icon} disabled={item.disabled} />
......@@ -523,18 +605,18 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
523605 Branch is behind upstream. Pull/rebase before opening a PR.
524606 </p>
525607 )}
526 {gitActionError && (
608 {visibleGitActionError && (
527609 <p className="px-3 pt-2 text-[11px] text-rose-500 dark:text-rose-300">
528 {gitActionError}
610 {visibleGitActionError}
529611 </p>
530612 )}
531613 </div>
532614 )}
533615 </div>
534616
535 {isGitModalOpen && gitModalAction && (
617 {isGitModalOpen && (
536618 <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"
538620 onMouseDown={() => {
539621 closeGitActionModal();
540622 }}
......@@ -552,13 +634,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
552634 <div className="flex items-center gap-3">
553635 <div className="rounded-2xl bg-accent p-3">
554636 <GitActionIcon
555 icon={
556 gitModalAction === "commit"
557 ? "commit"
558 : gitModalAction === "commit_push"
559 ? "push"
560 : "pr"
561 }
637 icon={selectedGitModalActionOption?.icon ?? "commit"}
562638 disabled={false}
563639 />
564640 </div>
......@@ -567,7 +643,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
567643 Git actions
568644 </p>
569645 <h3 className="text-3xl font-semibold tracking-tight text-foreground">
570 {gitActionModalTitle(gitModalAction)}
646 {gitActionModalTitle()}
571647 </h3>
572648 </div>
573649 </div>
......@@ -581,7 +657,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
581657 <XIcon className="h-5 w-5" />
582658 </button>
583659 </div>
584
585660 <div className="mt-6 space-y-2 rounded-2xl border border-border/80 bg-card/40 px-4 py-3">
586661 <div className="flex items-center justify-between gap-4 text-sm">
587662 <span className="text-muted-foreground/70">Branch</span>
......@@ -592,11 +667,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
592667 <div className="flex items-center justify-between gap-4 text-sm">
593668 <span className="text-muted-foreground/70">Changes</span>
594669 <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"}
596673 </span>
597674 </div>
598675 </div>
599
600676 <div className="mt-6">
601677 <label
602678 htmlFor="git-commit-message"
......@@ -611,62 +687,95 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
611687 placeholder="Leave blank to autogenerate a commit message"
612688 value={gitModalCommitMessage}
613689 onChange={(event) => setGitModalCommitMessage(event.target.value)}
614 disabled={isGitActionRunning || gitModalResult !== null}
690 disabled={!gitModalSelectionMode || isGitActionRunning}
615691 />
616692 <p className="mt-1.5 text-xs text-muted-foreground/65">
617693 Leave this empty to use AI-generated commit text.
618694 </p>
619695 </div>
620
621696 <div className="mt-6">
622697 <p className="text-sm font-medium text-foreground">Next steps</p>
623698 <div className="mt-2 overflow-hidden rounded-2xl border border-border">
624 {gitModalSteps.map((step, index) => {
625 const borderClass =
626 index < gitModalSteps.length - 1 ? "border-b border-border/70" : "";
627 const statusTextClass =
628 step.status === "failed"
629 ? "text-rose-500 dark:text-rose-300"
630 : step.status === "completed"
631 ? "text-emerald-600 dark:text-emerald-300"
632 : "text-muted-foreground/70";
633
634 return (
635 <div
636 key={step.id}
637 className={`flex items-start gap-3 bg-card/45 px-4 py-3 ${borderClass}`}
638 >
639 <span className="mt-0.5">
640 {step.status === "running" ? (
641 <Loader2Icon className="h-4 w-4 animate-spin text-foreground" />
642 ) : step.status === "completed" ? (
643 <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
644 ) : step.status === "skipped" ? (
645 <MinusIcon className="h-4 w-4 text-muted-foreground/70" />
646 ) : step.status === "failed" ? (
647 <XIcon className="h-4 w-4 text-rose-500 dark:text-rose-300" />
648 ) : (
649 <CircleIcon className="h-4 w-4 text-muted-foreground/60" />
650 )}
651 </span>
652 <div className="min-w-0">
653 <p className="text-sm text-foreground">{step.label}</p>
654 {step.detail && (
655 <p className={`mt-0.5 text-xs ${statusTextClass}`}>{step.detail}</p>
656 )}
699 {gitModalSelectionMode &&
700 gitModalActionOptions.map((option, index) => {
701 const borderClass =
702 index < gitModalActionOptions.length - 1 ? "border-b border-border/70" : "";
703 const selected = option.action === gitModalSelectedAction && !option.disabled;
704
705 return (
706 <button
707 key={option.action}
708 type="button"
709 className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors duration-150 ${borderClass} ${
710 selected ? "bg-accent/55" : "bg-card/45 hover:bg-accent/35"
711 } disabled:cursor-not-allowed disabled:bg-card/45 disabled:text-muted-foreground/65`}
712 disabled={option.disabled || isGitActionRunning}
713 onClick={() => {
714 setGitModalSelectedAction(option.action);
715 }}
716 >
717 <span className="mt-0.5">
718 <GitActionIcon icon={option.icon} disabled={option.disabled} />
719 </span>
720 <div className="min-w-0 flex-1">
721 <p className="text-sm text-foreground">{option.label}</p>
722 {option.detail && (
723 <p className="mt-0.5 text-xs text-muted-foreground/70">
724 {option.detail}
725 </p>
726 )}
727 </div>
728 {selected ? (
729 <CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-foreground" />
730 ) : null}
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>
657769 </div>
658 </div>
659 );
660 })}
770 );
771 })}
661772 </div>
662773 </div>
663
664 {(gitModalError ?? gitActionError) && (
774 {(gitModalError ?? visibleGitActionError) && (
665775 <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}
667777 </div>
668778 )}
669
670779 {gitModalResult?.pr.url && (
671780 <div className="mt-4 rounded-lg border border-border bg-card/40 px-3 py-2 text-xs text-muted-foreground/80">
672781 PR:{" "}
......@@ -680,7 +789,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
680789 </a>
681790 </div>
682791 )}
683
684792 <div className="mt-6 flex justify-end gap-2">
685793 <button
686794 type="button"
......@@ -697,9 +805,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
697805 onClick={() => {
698806 void runGitAction();
699807 }}
700 disabled={isGitActionRunning}
808 disabled={isGitActionRunning || !selectedGitModalActionOption}
701809 >
702 {isGitActionRunning ? "Running..." : runActionLabel(gitModalAction)}
810 {isGitActionRunning
811 ? "Running..."
812 : gitModalSelectionMode
813 ? "Continue"
814 : runActionLabel(gitModalSelectedAction)}
703815 </button>
704816 )}
705817 </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);