| ... | @@ -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"; |
| | 7 | import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 8 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 8 | import { | 9 | import { |
| 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"; |
| | 18 | import { GitHubIcon } from "./Icons"; |
| 18 | | 19 | |
| 19 | interface GitActionsControlProps { | 20 | interface GitActionsControlProps { |
| 20 | api: NativeApi | undefined; | 21 | api: NativeApi | undefined; |
| 21 | gitCwd: string | null; | 22 | gitCwd: string | null; |
| 22 | } | 23 | } |
| 23 | | 24 | |
| | 25 | type GitActionIconName = "commit" | "push" | "pr"; |
| | 26 | |
| 24 | interface GitActionMenuItem { | 27 | interface 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 | } |
| 30 | | 35 | |
| 31 | type GitProgressStepStatus = | 36 | interface 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 | |
| | 44 | type GitProgressStepStatus = "pending" | "running" | "completed" | "skipped" | "failed"; |
| 37 | | 45 | |
| 38 | interface GitProgressStep { | 46 | interface 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 | } |
| 44 | | 52 | |
| 45 | function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boolean }) { | 53 | function 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"; |
| 47 | | 55 | |
| 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 | } |
| 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 { | 67 | function 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 | } |
| 64 | | 70 | |
| 65 | function initialGitProgressSteps( | 71 | function initialGitProgressSteps( |
| ... | @@ -119,102 +125,198 @@ function updateProgressStep( | ... | @@ -119,102 +125,198 @@ function updateProgressStep( |
| 119 | } | 125 | } |
| 120 | | 126 | |
| 121 | function runActionLabel(action: GitStackedAction): string { | 127 | function 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 | } |
| 126 | | 132 | |
| 127 | function isViewPrOnlyAction( | 133 | function 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 | |
| | 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 | export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { | 253 | export 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); | | |
| 165 | | 265 | |
| 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; | | |
| 197 | | 269 | |
| 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 | }); |
| 201 | | 289 | |
| 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 | } | | |
| 208 | | 291 | |
| 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]); |
| 214 | | 315 | |
| 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 |
| 224 | | 326 | |
| 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]); |
| 233 | | 336 | |
| 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 | ); |
| 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 | 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; |
| 271 | | 367 | |
| 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 | } |
| 323 | | 419 | |
| 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.number | 457 | 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 | ]); |
| 401 | | 492 | |
| 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]); |
| 410 | | 502 | |
| 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 | }; | | |
| 432 | | 506 | |
| 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]); |
| 438 | | 512 | |
| 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 | <button | 568 | <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> |
| 534 | | 616 | |
| 535 | {isGitModalOpen && gitModalAction && ( | 617 | {isGitModalOpen && ( |
| 536 | <div | 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 | 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 | <GitActionIcon | 636 | <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 actions | 643 | 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 | <label | 677 | <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} |
| 633 | | 708 | 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 | <div | 710 | 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> |
| 663 | | 774 | {(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 | <button | 793 | <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> |