| ... | ... | @@ -4,36 +4,44 @@ import { |
| 4 | 4 | type GitStatusResult, |
| 5 | 5 | type NativeApi, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 7 | 8 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 8 | 9 | import { |
| 9 | 10 | CheckIcon, |
| 10 | 11 | CircleIcon, |
| 11 | 12 | CloudUploadIcon, |
| 12 | 13 | GitCommitIcon, |
| 13 | | GithubIcon, |
| 14 | 14 | Loader2Icon, |
| 15 | 15 | MinusIcon, |
| 16 | 16 | XIcon, |
| 17 | 17 | } from "lucide-react"; |
| 18 | import { GitHubIcon } from "./Icons"; |
| 18 | 19 | |
| 19 | 20 | interface GitActionsControlProps { |
| 20 | 21 | api: NativeApi | undefined; |
| 21 | 22 | gitCwd: string | null; |
| 22 | 23 | } |
| 23 | 24 | |
| 25 | type GitActionIconName = "commit" | "push" | "pr"; |
| 26 | |
| 24 | 27 | interface GitActionMenuItem { |
| 25 | | id: GitStackedAction; |
| 28 | id: "commit" | "push" | "pr"; |
| 26 | 29 | label: string; |
| 27 | 30 | disabled: boolean; |
| 28 | | icon: "commit" | "push" | "pr"; |
| 31 | icon: GitActionIconName; |
| 32 | kind: "open_modal" | "run_action" | "open_pr"; |
| 33 | action?: GitStackedAction; |
| 29 | 34 | } |
| 30 | 35 | |
| 31 | | type GitProgressStepStatus = |
| 32 | | | "pending" |
| 33 | | | "running" |
| 34 | | | "completed" |
| 35 | | | "skipped" |
| 36 | | | "failed"; |
| 36 | interface GitModalActionOption { |
| 37 | action: GitStackedAction; |
| 38 | label: string; |
| 39 | disabled: boolean; |
| 40 | icon: GitActionIconName; |
| 41 | detail?: string; |
| 42 | } |
| 43 | |
| 44 | type GitProgressStepStatus = "pending" | "running" | "completed" | "skipped" | "failed"; |
| 37 | 45 | |
| 38 | 46 | interface GitProgressStep { |
| 39 | 47 | id: "generate" | "commit" | "push" | "pr"; |
| ... | ... | @@ -42,7 +50,7 @@ interface GitProgressStep { |
| 42 | 50 | detail?: string; |
| 43 | 51 | } |
| 44 | 52 | |
| 45 | | function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boolean }) { |
| 53 | function GitActionIcon(props: { icon: GitActionIconName; disabled: boolean }) { |
| 46 | 54 | const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85"; |
| 47 | 55 | |
| 48 | 56 | if (props.icon === "commit") { |
| ... | ... | @@ -53,13 +61,11 @@ function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boole |
| 53 | 61 | return <CloudUploadIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />; |
| 54 | 62 | } |
| 55 | 63 | |
| 56 | | return <GithubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />; |
| 64 | return <GitHubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />; |
| 57 | 65 | } |
| 58 | 66 | |
| 59 | | function 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"; |
| 67 | function gitActionModalTitle(): string { |
| 68 | return "Commit your changes"; |
| 63 | 69 | } |
| 64 | 70 | |
| 65 | 71 | function initialGitProgressSteps( |
| ... | ... | @@ -119,102 +125,198 @@ function updateProgressStep( |
| 119 | 125 | } |
| 120 | 126 | |
| 121 | 127 | function 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"; |
| 125 | 131 | } |
| 126 | 132 | |
| 127 | | function isViewPrOnlyAction( |
| 128 | | action: GitStackedAction, |
| 133 | function buildGitActionMenuItems( |
| 129 | 134 | 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 | |
| 201 | function 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 | |
| 236 | function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] { |
| 237 | return ["git", "status", gitCwd]; |
| 238 | } |
| 239 | |
| 240 | function 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 | } |
| 135 | 252 | |
| 136 | 253 | export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { |
| 254 | const queryClient = useQueryClient(); |
| 137 | 255 | 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); |
| 140 | 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 | 260 | const [gitModalCommitMessage, setGitModalCommitMessage] = useState(""); |
| 143 | 261 | const [gitModalProgress, setGitModalProgress] = useState<GitProgressStep[]>([]); |
| 144 | 262 | const [gitModalError, setGitModalError] = useState<string | null>(null); |
| 145 | 263 | const [gitModalResult, setGitModalResult] = useState<GitRunStackedActionResult | null>(null); |
| 146 | 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); |
| 165 | 265 | |
| 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), |
| 195 | 268 | ); |
| 196 | | const gitModalSteps = gitModalProgress.length > 0 ? gitModalProgress : gitModalPreviewSteps; |
| 197 | 269 | |
| 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 | }); |
| 201 | 289 | |
| 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; |
| 208 | 291 | |
| 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"] }); |
| 212 | 313 | setGitActionError(null); |
| 213 | | }, [api, gitCwd]); |
| 314 | }, [api, gitCwd, queryClient]); |
| 214 | 315 | |
| 215 | | const openGitActionModal = useCallback((action: GitStackedAction) => { |
| 316 | const openGitActionModal = useCallback(() => { |
| 216 | 317 | setIsGitMenuOpen(false); |
| 217 | | setGitModalAction(action); |
| 318 | setIsGitModalOpen(true); |
| 319 | setGitModalSelectedAction("commit"); |
| 218 | 320 | setGitModalCommitMessage(""); |
| 219 | 321 | setGitModalProgress([]); |
| 220 | 322 | setGitModalError(null); |
| ... | ... | @@ -224,7 +326,8 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 224 | 326 | |
| 225 | 327 | const closeGitActionModal = useCallback(() => { |
| 226 | 328 | if (isGitActionRunning) return; |
| 227 | | setGitModalAction(null); |
| 329 | setIsGitModalOpen(false); |
| 330 | setGitModalSelectedAction("commit"); |
| 228 | 331 | setGitModalCommitMessage(""); |
| 229 | 332 | setGitModalProgress([]); |
| 230 | 333 | setGitModalError(null); |
| ... | ... | @@ -232,44 +335,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 232 | 335 | }, [isGitActionRunning]); |
| 233 | 336 | |
| 234 | 337 | 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); |
| 261 | 340 | }, |
| 262 | | [api, gitCwd, refreshGitStatus], |
| 341 | [runImmediateGitActionMutation], |
| 263 | 342 | ); |
| 264 | 343 | |
| 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 | 360 | const runGitAction = useCallback(async () => { |
| 266 | | if (!api || !gitCwd || !gitModalAction) return; |
| 361 | if (!api || !gitCwd || !isGitModalOpen) return; |
| 362 | if (!selectedGitModalActionOption || selectedGitModalActionOption.disabled) return; |
| 267 | 363 | const actionCwd = gitCwd; |
| 268 | | const action = gitModalAction; |
| 364 | const action = selectedGitModalActionOption.action; |
| 269 | 365 | const commitMessage = gitModalCommitMessage.trim(); |
| 270 | 366 | const includeGeneratedCommitMessage = commitMessage.length === 0; |
| 271 | 367 | |
| 272 | | setIsGitActionRunning(true); |
| 368 | setIsGitModalActionRunning(true); |
| 273 | 369 | setGitModalError(null); |
| 274 | 370 | setGitActionError(null); |
| 275 | 371 | setGitModalResult(null); |
| ... | ... | @@ -322,11 +418,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 322 | 418 | } |
| 323 | 419 | |
| 324 | 420 | 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."); |
| 330 | 422 | } else { |
| 331 | 423 | updateStep("commit", "skipped", "No local changes to commit."); |
| 332 | 424 | } |
| ... | ... | @@ -362,9 +454,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 362 | 454 | updateStep( |
| 363 | 455 | "pr", |
| 364 | 456 | "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.", |
| 368 | 458 | ); |
| 369 | 459 | } else if (prRun.pr.status === "created") { |
| 370 | 460 | updateStep( |
| ... | ... | @@ -388,53 +478,37 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 388 | 478 | return updateProgressStep(steps, active.id, "failed", message); |
| 389 | 479 | }); |
| 390 | 480 | } 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); |
| 399 | 483 | } |
| 400 | | }, [api, gitCwd, gitModalAction, gitModalCommitMessage, refreshGitStatus]); |
| 484 | }, [ |
| 485 | api, |
| 486 | gitCwd, |
| 487 | gitModalCommitMessage, |
| 488 | isGitModalOpen, |
| 489 | refreshGitStatus, |
| 490 | selectedGitModalActionOption, |
| 491 | ]); |
| 401 | 492 | |
| 402 | 493 | useEffect(() => { |
| 403 | 494 | setGitActionError(null); |
| 404 | 495 | setGitModalError(null); |
| 405 | | setGitModalAction(null); |
| 496 | setIsGitModalOpen(false); |
| 497 | setGitModalSelectedAction("commit"); |
| 406 | 498 | setGitModalCommitMessage(""); |
| 407 | 499 | setGitModalProgress([]); |
| 408 | 500 | setGitModalResult(null); |
| 409 | 501 | }, [gitCwd]); |
| 410 | 502 | |
| 411 | 503 | 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; |
| 432 | 506 | |
| 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]); |
| 438 | 512 | |
| 439 | 513 | useEffect(() => { |
| 440 | 514 | if (!isGitMenuOpen) return; |
| ... | ... | @@ -492,16 +566,24 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 492 | 566 | {gitActionMenuItems.map((item) => { |
| 493 | 567 | return ( |
| 494 | 568 | <button |
| 495 | | key={item.id} |
| 569 | key={`${item.id}-${item.label}`} |
| 496 | 570 | type="button" |
| 497 | 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 | 572 | disabled={item.disabled} |
| 499 | 573 | 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(); |
| 502 | 581 | return; |
| 503 | 582 | } |
| 504 | | openGitActionModal(item.id); |
| 583 | |
| 584 | if (item.action) { |
| 585 | void runGitActionImmediately(item.action); |
| 586 | } |
| 505 | 587 | }} |
| 506 | 588 | > |
| 507 | 589 | <GitActionIcon icon={item.icon} disabled={item.disabled} /> |
| ... | ... | @@ -523,18 +605,18 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 523 | 605 | Branch is behind upstream. Pull/rebase before opening a PR. |
| 524 | 606 | </p> |
| 525 | 607 | )} |
| 526 | | {gitActionError && ( |
| 608 | {visibleGitActionError && ( |
| 527 | 609 | <p className="px-3 pt-2 text-[11px] text-rose-500 dark:text-rose-300"> |
| 528 | | {gitActionError} |
| 610 | {visibleGitActionError} |
| 529 | 611 | </p> |
| 530 | 612 | )} |
| 531 | 613 | </div> |
| 532 | 614 | )} |
| 533 | 615 | </div> |
| 534 | 616 | |
| 535 | | {isGitModalOpen && gitModalAction && ( |
| 617 | {isGitModalOpen && ( |
| 536 | 618 | <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 | 620 | onMouseDown={() => { |
| 539 | 621 | closeGitActionModal(); |
| 540 | 622 | }} |
| ... | ... | @@ -552,13 +634,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 552 | 634 | <div className="flex items-center gap-3"> |
| 553 | 635 | <div className="rounded-2xl bg-accent p-3"> |
| 554 | 636 | <GitActionIcon |
| 555 | | icon={ |
| 556 | | gitModalAction === "commit" |
| 557 | | ? "commit" |
| 558 | | : gitModalAction === "commit_push" |
| 559 | | ? "push" |
| 560 | | : "pr" |
| 561 | | } |
| 637 | icon={selectedGitModalActionOption?.icon ?? "commit"} |
| 562 | 638 | disabled={false} |
| 563 | 639 | /> |
| 564 | 640 | </div> |
| ... | ... | @@ -567,7 +643,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 567 | 643 | Git actions |
| 568 | 644 | </p> |
| 569 | 645 | <h3 className="text-3xl font-semibold tracking-tight text-foreground"> |
| 570 | | {gitActionModalTitle(gitModalAction)} |
| 646 | {gitActionModalTitle()} |
| 571 | 647 | </h3> |
| 572 | 648 | </div> |
| 573 | 649 | </div> |
| ... | ... | @@ -581,7 +657,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 581 | 657 | <XIcon className="h-5 w-5" /> |
| 582 | 658 | </button> |
| 583 | 659 | </div> |
| 584 | | |
| 585 | 660 | <div className="mt-6 space-y-2 rounded-2xl border border-border/80 bg-card/40 px-4 py-3"> |
| 586 | 661 | <div className="flex items-center justify-between gap-4 text-sm"> |
| 587 | 662 | <span className="text-muted-foreground/70">Branch</span> |
| ... | ... | @@ -592,11 +667,12 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 592 | 667 | <div className="flex items-center justify-between gap-4 text-sm"> |
| 593 | 668 | <span className="text-muted-foreground/70">Changes</span> |
| 594 | 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 | 673 | </span> |
| 597 | 674 | </div> |
| 598 | 675 | </div> |
| 599 | | |
| 600 | 676 | <div className="mt-6"> |
| 601 | 677 | <label |
| 602 | 678 | htmlFor="git-commit-message" |
| ... | ... | @@ -611,62 +687,95 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 611 | 687 | placeholder="Leave blank to autogenerate a commit message" |
| 612 | 688 | value={gitModalCommitMessage} |
| 613 | 689 | onChange={(event) => setGitModalCommitMessage(event.target.value)} |
| 614 | | disabled={isGitActionRunning || gitModalResult !== null} |
| 690 | disabled={!gitModalSelectionMode || isGitActionRunning} |
| 615 | 691 | /> |
| 616 | 692 | <p className="mt-1.5 text-xs text-muted-foreground/65"> |
| 617 | 693 | Leave this empty to use AI-generated commit text. |
| 618 | 694 | </p> |
| 619 | 695 | </div> |
| 620 | | |
| 621 | 696 | <div className="mt-6"> |
| 622 | 697 | <p className="text-sm font-medium text-foreground">Next steps</p> |
| 623 | 698 | <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> |
| 657 | 769 | </div> |
| 658 | | </div> |
| 659 | | ); |
| 660 | | })} |
| 770 | ); |
| 771 | })} |
| 661 | 772 | </div> |
| 662 | 773 | </div> |
| 663 | | |
| 664 | | {(gitModalError ?? gitActionError) && ( |
| 774 | {(gitModalError ?? visibleGitActionError) && ( |
| 665 | 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 | 777 | </div> |
| 668 | 778 | )} |
| 669 | | |
| 670 | 779 | {gitModalResult?.pr.url && ( |
| 671 | 780 | <div className="mt-4 rounded-lg border border-border bg-card/40 px-3 py-2 text-xs text-muted-foreground/80"> |
| 672 | 781 | PR:{" "} |
| ... | ... | @@ -680,7 +789,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 680 | 789 | </a> |
| 681 | 790 | </div> |
| 682 | 791 | )} |
| 683 | | |
| 684 | 792 | <div className="mt-6 flex justify-end gap-2"> |
| 685 | 793 | <button |
| 686 | 794 | type="button" |
| ... | ... | @@ -697,9 +805,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 697 | 805 | onClick={() => { |
| 698 | 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 | 815 | </button> |
| 704 | 816 | )} |
| 705 | 817 | </div> |