import { useAtomValue } from "@effect/atom-react"; import { type ScopedThreadRef } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import type { GitRunStackedActionResult, GitStackedAction, SourceControlCloneProtocol, SourceControlProviderDiscoveryItem, SourceControlProviderKind, SourceControlPublishRepositoryResult, SourceControlRepositoryVisibility, VcsStatusResult, } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { type MouseEvent, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState, } from "react"; import { flushSync } from "react-dom"; import { CheckIcon, ChevronDownIcon, CloudDownloadIcon, CloudUploadIcon, FileDiffIcon, GitBranchPlusIcon, GitCommitIcon, InfoIcon, LockIcon, GlobeIcon, } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; import { toggleVariants } from "~/components/ui/toggle"; import { cn } from "~/lib/utils"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { buildMenuItems, formatGitActionElapsed, GIT_ACTION_SUCCESS_VISIBLE_MS, type GitActionProgressPresentation, type GitActionIconName, type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveGitActionProgressPresentation, resolveGitActionResultToastTiming, resolveLiveThreadBranchUpdate, resolveThreadBranchMetadataPatch, resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { AnimatedHeight } from "./AnimatedHeight"; import { StartTruncatedPath } from "./StartTruncatedPath"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "~/components/ui/dialog"; import { Group, GroupSeparator } from "~/components/ui/group"; import { Input } from "~/components/ui/input"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useOpenInPreferredEditor } from "~/editorPreferences"; import { useGitStackedAction, useSourceControlActionRunning, useSourceControlPublishRepositoryAction, useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; import { useThreadShell } from "~/state/entities"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; import { sourceControlEnvironment } from "~/state/sourceControl"; import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; import { vcsActionManager, vcsEnvironment } from "~/state/vcs"; import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { THREAD_DETAILS_PANEL_CHEVRON_CLASS, THREAD_DETAILS_PANEL_ICON_CLASS, THREAD_DETAILS_PANEL_ROW_POPUP_CLASS, THREAD_DETAILS_PANEL_ROW_CLASS, THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./chat/threadDetailsPanelStyles"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useOpenLink } from "~/browser/useOpenLink"; interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId; /** * Opens the thread's own change request beside it. Absent when the thread has no project to * place it against, in which case it still opens in the browser. */ onOpenPullRequest?: ((number: number) => void) | undefined; displayMode?: "toolbar" | "panel"; onOpenChanges?: () => void; } interface PendingDefaultBranchAction { action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; commitMessage?: string; onConfirmed?: () => void; filePaths?: string[]; } type PublishProviderKind = Extract< SourceControlProviderKind, "github" | "gitlab" | "bitbucket" | "azure-devops" >; type GitActionToastId = ReturnType; interface RunGitActionWithToastInput { action: GitStackedAction; commitMessage?: string; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; statusOverride?: VcsStatusResult | null; featureBranch?: boolean; filePaths?: string[]; } interface InlineGitActionSuccess { readonly title: string; readonly description: string | null; readonly scopeKey: string; } const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; type RefreshVcsStatus = (target: { readonly environmentId: ScopedThreadRef["environmentId"]; readonly input: { readonly cwd: string }; }) => Promise; function requestVcsStatusRefresh( refresh: RefreshVcsStatus, environmentId: ScopedThreadRef["environmentId"] | null, cwd: string | null, ): void { if (environmentId === null || cwd === null) { return; } void refresh({ environmentId, input: { cwd } }); } const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; const PUBLISH_PROVIDER_OPTIONS = [ { value: "github", label: "GitHub", description: "github.com", host: "github.com", pathPlaceholder: "owner/repo", Icon: GitHubIcon, }, { value: "gitlab", label: "GitLab", description: "gitlab.com", host: "gitlab.com", pathPlaceholder: "group/project", Icon: GitLabIcon, }, { value: "bitbucket", label: "Bitbucket", description: "bitbucket.org", host: "bitbucket.org", pathPlaceholder: "workspace/repository", Icon: BitbucketIcon, }, { value: "azure-devops", label: "Azure DevOps", description: "dev.azure.com", host: "dev.azure.com", pathPlaceholder: "project/repository", Icon: AzureDevOpsIcon, }, ] as const satisfies ReadonlyArray<{ readonly value: PublishProviderKind; readonly label: string; readonly description: string; readonly host: string; readonly pathPlaceholder: string; readonly Icon: typeof GitHubIcon; }>; function publishProviderOption(provider: PublishProviderKind) { return ( PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? PUBLISH_PROVIDER_OPTIONS[0] ); } function isPublishProviderKind( provider: SourceControlProviderKind, ): provider is PublishProviderKind { return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); } function getPublishProviderReadiness(input: { provider: PublishProviderKind; sourceControlProviders: ReadonlyArray; }): { readonly ready: boolean; readonly hint: string | null } { const discovered = input.sourceControlProviders.find( (provider) => provider.kind === input.provider, ); if (!discovered) { return { ready: false, hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", }; } if (discovered.status !== "available") { return { ready: false, hint: discovered.installHint }; } if (discovered.auth.status === "unauthenticated") { return { ready: false, hint: Option.getOrNull(discovered.auth.detail) ?? `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, }; } return { ready: true, hint: null }; } function getMenuActionDisabledReason({ item, gitStatus, isBusy, hasPrimaryRemote, }: { item: GitActionMenuItem; gitStatus: VcsStatusResult | null; isBusy: boolean; hasPrimaryRemote: boolean; }): string | null { if (!item.disabled) return null; if (isBusy) return "Git action in progress."; if (!gitStatus) return "Git status is unavailable."; const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology; if (item.id === "commit") { if (!hasChanges) { return "Worktree is clean. Make changes before committing."; } return "Commit is currently unavailable."; } if (item.id === "push") { if (!hasBranch) { return "Detached HEAD: checkout a refName before pushing."; } if (hasChanges) { return "Commit or stash local changes before pushing."; } if (isBehind) { return "Branch is behind upstream. Pull/rebase before pushing."; } if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return 'Add an "origin" remote before pushing.'; } if (!isAhead) { return "No local commits to push."; } return "Push is currently unavailable."; } if (!hasBranch) { return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; } if (hasChanges) { return `Commit local changes before creating a ${terminology.singular}.`; } if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return `Add an "origin" remote before creating a ${terminology.singular}.`; } if (!isAhead) { return `No local commits to include in a ${terminology.singular}.`; } if (isBehind) { return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; } return `Create ${terminology.singular} is currently unavailable.`; } const COMMIT_DIALOG_TITLE = "Commit changes"; const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; function GitActionItemIcon({ icon, SourceControlIcon, }: { icon: GitActionIconName; SourceControlIcon: ReturnType["Icon"]; }) { if (icon === "commit") return ; if (icon === "push") return ; return ; } function GitQuickActionIcon({ quickAction, SourceControlIcon, className = "size-3.5", }: { quickAction: GitQuickAction; SourceControlIcon: ReturnType["Icon"]; className?: string; }) { const iconClassName = className; if (quickAction.kind === "open_publish") return ; if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; if (quickAction.action === "push" || quickAction.action === "commit_push") { return ; } return ; } if (quickAction.label === "Commit") return ; if (quickAction.label === "Push") return ; return ; } function GitActionElapsedTime({ startedAtMs, className, }: { startedAtMs: number | null; className?: string; }) { const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { if (startedAtMs === null) { return; } const intervalId = window.setInterval(() => { setNowMs(Date.now()); }, 1_000); return () => window.clearInterval(intervalId); }, [startedAtMs]); const elapsed = formatGitActionElapsed(startedAtMs, nowMs); if (!elapsed) { return null; } return

{elapsed}

; } function GitActionProgressButtonContent({ progress, isPanel, }: { progress: GitActionProgressPresentation; isPanel: boolean; }) { const hasOutput = progress.output !== null; return (
); } function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSuccess }) { const hasDescription = success.description !== null; return (
); } interface PublishRepositoryDialogProps { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: ScopedThreadRef["environmentId"] | null; /** Thread the dialog was opened from, so the new repository can open beside it. */ readonly threadRef: ScopedThreadRef | null; readonly gitCwd: string; } function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const openLink = useOpenLink(props.threadRef); const navigate = useNavigate(); const sourceControlDiscovery = useEnvironmentQuery( props.environmentId === null ? null : sourceControlEnvironment.discovery({ environmentId: props.environmentId, input: {}, }), ); const [selectedPublishProvider, setSelectedPublishProvider] = useState(null); const [publishRepositoryOverride, setPublishRepositoryOverride] = useState(null); const [publishVisibility, setPublishVisibility] = useState("private"); const [publishRemoteName, setPublishRemoteName] = useState("origin"); const [publishProtocol, setPublishProtocol] = useState("ssh"); const [publishWizardStep, setPublishWizardStep] = useState(0); const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); const [publishError, setPublishError] = useState(null); const [publishResult, setPublishResult] = useState( null, ); const sourceControlScope = useMemo( () => ({ environmentId: props.environmentId, cwd: props.gitCwd, }), [props.environmentId, props.gitCwd], ); const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); const publishAccountByProvider = useMemo(() => { const accounts: Record = { github: null, gitlab: null, bitbucket: null, "azure-devops": null, }; for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { if (isPublishProviderKind(provider.kind)) { accounts[provider.kind] = Option.getOrNull(provider.auth.account); } } return accounts; }, [sourceControlDiscovery.data]); const publishProviderReadiness = useMemo(() => { const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; return Object.fromEntries( PUBLISH_PROVIDER_OPTIONS.map((option) => [ option.value, getPublishProviderReadiness({ provider: option.value, sourceControlProviders, }), ]), ) as Record; }, [sourceControlDiscovery.data]); const hasReadyPublishProvider = useMemo( () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready), [publishProviderReadiness], ); const sortedPublishProviderOptions = useMemo( () => PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => { const leftReady = publishProviderReadiness[left.value].ready; const rightReady = publishProviderReadiness[right.value].ready; if (leftReady !== rightReady) { return leftReady ? -1 : 1; } return left.label.localeCompare(right.label); }), [publishProviderReadiness], ); const firstReadyPublishProvider = sortedPublishProviderOptions.find( (option) => publishProviderReadiness[option.value].ready, )?.value; const publishProvider = selectedPublishProvider !== null && publishProviderReadiness[selectedPublishProvider].ready ? selectedPublishProvider : (firstReadyPublishProvider ?? selectedPublishProvider ?? "github"); const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; const publishRepositoryPrefill = publishAccountByProvider[publishProvider] ? `${publishAccountByProvider[publishProvider]}/` : ""; const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; const currentPublishProvider = publishProviderOption(publishProvider); const publishHost = currentPublishProvider.host; const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; const publishProviderLabel = currentPublishProvider.label; const publishWizardSteps = ["Provider", "Repository", "Summary"] as const; const publishWizardStepSummaries = [ publishProviderLabel, publishResult?.repository.nameWithOwner ?? null, null, ] as const; const canSubmitPublishRepository = useMemo(() => { if (!selectedPublishProviderReadiness.ready) return false; if (publishRepositoryAction.isPending) return false; const repositoryParts = publishRepository.trim().split("/"); const owner = repositoryParts[0]?.trim() ?? ""; const rest = repositoryParts.slice(1); const name = rest.join("/").trim(); return owner.length > 0 && name.length > 0; }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]); const submitPublishRepository = useCallback(() => { if (!canSubmitPublishRepository) { return; } setPublishError(null); void (async () => { const result = await publishRepositoryAction.run({ provider: publishProvider, repository: publishRepository.trim(), visibility: publishVisibility, remoteName: publishRemoteName.trim() || "origin", protocol: publishProtocol, }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setPublishError(error instanceof Error ? error.message : "An error occurred."); } return; } flushSync(() => { setPublishResult(result.value); setPublishWizardStep(2); }); })(); }, [ canSubmitPublishRepository, props.environmentId, props.gitCwd, publishProtocol, publishProvider, publishRemoteName, publishRepository, publishRepositoryAction, publishVisibility, ]); const resetState = useCallback(() => { setPublishRemoteName("origin"); setPublishRepositoryOverride(null); setPublishWizardStep(0); setPublishAdvancedOpen(false); setPublishError(null); setPublishResult(null); }, []); const handleOpenChange = useCallback( (open: boolean) => { props.onOpenChange(open); if (!open) { resetState(); } }, [props, resetState], ); const openSourceControlSettings = useCallback(() => { handleOpenChange(false); void navigate({ to: "/settings/source-control" }); }, [handleOpenChange, navigate]); return (
Publish repository Pick where to host it, then point us at a repo to push to.
{publishWizardSteps.map((label, index) => { const isComplete = index < publishWizardStep; const isClickable = publishWizardStep !== 2 && index < publishWizardSteps.length - 1 && index <= publishWizardStep; return ( ); })}
Provider { setSelectedPublishProvider(value as PublishProviderKind); setPublishRepositoryOverride(null); }} aria-labelledby="publish-provider-cards-label" className="grid grid-cols-2 gap-2.5" > {sortedPublishProviderOptions.map((option) => { const readiness = publishProviderReadiness[option.value]; const isSelected = publishProvider === option.value && readiness.ready; if (!readiness.ready) { return (
{option.label} { event.preventDefault(); event.stopPropagation(); openSourceControlSettings(); }} > Setup Required } /> {readiness.hint ?? "Open Settings -> Source Control to configure this provider."}
); } return ( {option.label} ); })}
{publishHost}/ { setPublishRepositoryOverride(event.target.value); }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); submitPublishRepository(); } }} placeholder={publishPathPlaceholder} disabled={publishRepositoryAction.isPending} className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" />
Visibility setPublishVisibility(value as SourceControlRepositoryVisibility) } aria-labelledby="publish-visibility-cards-label" disabled={publishRepositoryAction.isPending} className="grid grid-cols-2 gap-2.5" > {[ { value: "private" as const, label: "Private", description: "Only invited people", Icon: LockIcon, }, { value: "public" as const, label: "Public", description: "Anyone on the web", Icon: GlobeIcon, }, ].map((option) => { const isSelected = publishVisibility === option.value; return ( {option.label} {option.description} ); })}
{publishAdvancedOpen ? (
Protocol { if (protocol === "ssh" || protocol === "https") { setPublishProtocol(protocol); } }} aria-labelledby="publish-protocol-label" disabled={publishRepositoryAction.isPending} > {(["ssh", "https"] as const).map((protocol) => ( {protocol.toUpperCase()} ))}
) : null}
{publishRepositoryAction.isPending ? (
Publishing repository to {publishProviderLabel}...
) : null} {publishError && !publishRepositoryAction.isPending ? (

Publish failed

{publishError}

) : null}
{publishResult ? ( <>

{publishResult.status === "pushed" ? "Repository published" : "Repository created"}

{publishResult.status === "pushed" ? `${publishResult.branch} is now live on ${publishProviderLabel}.` : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`}

{publishResult.repository.nameWithOwner}
) : (
Publish result unavailable.
)}
{publishWizardStep === 2 ? ( ) : ( <> {publishWizardStep < 1 ? ( ) : ( )} )}
); } export default function GitActionsControl({ gitCwd, activeThreadRef, draftId, displayMode = "toolbar", onOpenChanges, }: GitActionsControlProps) { const isPanel = displayMode === "panel"; const ActionGroup = isPanel ? "div" : Group; const panelAnchorRef = useRef(null); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, "thread branch metadata update", ); const activeEnvironmentId = activeThreadRef?.environmentId ?? null; const successScopeKey = `${activeEnvironmentId ?? ""}\u0000${gitCwd ?? ""}`; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId)); const openInPreferredEditor = useOpenInPreferredEditor( activeEnvironmentId, serverConfig?.availableEditors ?? [], ); const threadToastData = useMemo( () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), [activeThreadRef], ); const activeServerThread = useThreadShell(activeThreadRef); const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null, ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [excludedFiles, setExcludedFiles] = useState>(new Set()); const [isEditingFiles, setIsEditingFiles] = useState(false); const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); const [inlineSuccess, setInlineSuccess] = useState(null); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); const sourceControlScope = useMemo( () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }), [activeEnvironmentId, gitCwd], ); const vcsActionState = useAtomValue(vcsActionManager.stateAtom(sourceControlScope)); const visibleInlineSuccess = inlineSuccess?.scopeKey === successScopeKey ? inlineSuccess : null; let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise; useEffect(() => { if (!inlineSuccess) return; const timeoutId = window.setTimeout(() => { setInlineSuccess(null); }, GIT_ACTION_SUCCESS_VISIBLE_MS); return () => window.clearTimeout(timeoutId); }, [inlineSuccess]); const persistThreadBranchSync = useCallback( (branch: string | null) => { if (!activeThreadRef) { return; } if (activeServerThread) { if (activeServerThread.branch === branch) { return; } void updateThreadMetadata({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, ...resolveThreadBranchMetadataPatch(branch, activeServerThread.branch), }, }); return; } if (!activeDraftThread || activeDraftThread.branch === branch) { return; } setDraftThreadContext(draftId ?? activeThreadRef, { branch, worktreePath: activeDraftThread.worktreePath, }); }, [ activeDraftThread, activeServerThread, activeThreadRef, draftId, setDraftThreadContext, updateThreadMetadata, ], ); const syncThreadBranchAfterGitAction = useCallback( (result: GitRunStackedActionResult) => { const branchUpdate = resolveThreadBranchUpdate(result); if (!branchUpdate) { return; } persistThreadBranchSync(branchUpdate.branch); }, [persistThreadBranchSync], ); const gitStatusQuery = useEnvironmentQuery( activeEnvironmentId !== null && gitCwd !== null ? vcsEnvironment.status({ environmentId: activeEnvironmentId, input: { cwd: gitCwd }, }) : null, ); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); const { data: gitStatus, error: gitStatusError } = gitStatusQuery; const sourceControlPresentation = useMemo( () => getSourceControlPresentation(gitStatus?.sourceControlProvider), [gitStatus?.sourceControlProvider], ); const changeRequestTerminology = sourceControlPresentation.terminology; const SourceControlIcon = sourceControlPresentation.Icon; // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; const allFiles = gitStatusForActions?.workingTree.files ?? []; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; const initAction = useVcsInitAction(sourceControlScope); const runImmediateGitAction = useGitStackedAction(sourceControlScope); const pullAction = useVcsPullAction(sourceControlScope); const isGitActionRunning = useSourceControlActionRunning( sourceControlScope, RUNNING_SOURCE_CONTROL_ACTIONS, ); const isSelectingWorktreeBase = !activeServerThread && activeDraftThread?.envMode === "worktree" && activeDraftThread.worktreePath === null; useEffect(() => { if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { return; } const branchUpdate = resolveLiveThreadBranchUpdate({ threadBranch: activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, }); if (!branchUpdate) { return; } persistThreadBranchSync(branchUpdate.branch); }, [ activeServerThread, activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, isSelectingWorktreeBase, persistThreadBranchSync, ]); const isDefaultRef = useMemo(() => { return gitStatusForActions?.isDefaultRef ?? false; }, [gitStatusForActions?.isDefaultRef]); const gitActionMenuItems = useMemo( () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote), [gitStatusForActions, hasPrimaryRemote, isGitActionRunning], ); const quickAction = useMemo( () => resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") : null; const gitActionProgress = resolveGitActionProgressPresentation(vcsActionState); const pendingDefaultBranchActionCopy = pendingDefaultBranchAction ? resolveDefaultBranchActionDialogCopy({ action: pendingDefaultBranchAction.action, branchName: pendingDefaultBranchAction.branchName, includesCommit: pendingDefaultBranchAction.includesCommit, terminology: changeRequestTerminology, }) : null; useEffect(() => { if (gitCwd === null) { return; } let refreshTimeout: number | null = null; const scheduleRefreshCurrentGitStatus = () => { if (refreshTimeout !== null) { window.clearTimeout(refreshTimeout); } refreshTimeout = window.setTimeout(() => { refreshTimeout = null; requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); }; const handleVisibilityChange = () => { if (document.visibilityState === "visible") { scheduleRefreshCurrentGitStatus(); } }; window.addEventListener("focus", scheduleRefreshCurrentGitStatus); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { if (refreshTimeout !== null) { window.clearTimeout(refreshTimeout); } window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); runGitActionWithToast = useEffectEvent( async ({ action, commitMessage, onConfirmed, skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, filePaths, }: RunGitActionWithToastInput) => { const actionStatus = statusOverride ?? gitStatusForActions; const actionBranch = actionStatus?.refName ?? null; const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; const actionCanCommit = action === "commit" || action === "commit_push" || action === "commit_push_pr"; const includesCommit = actionCanCommit && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch); if ( !skipDefaultBranchPrompt && requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && actionBranch ) { if ( action !== "push" && action !== "create_pr" && action !== "commit_push" && action !== "commit_push_pr" ) { return; } setPendingDefaultBranchAction({ action, branchName: actionBranch, includesCommit, ...(commitMessage ? { commitMessage } : {}), ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), }); return; } onConfirmed?.(); setInlineSuccess(null); const scopedToastData = threadToastData ? { ...threadToastData } : undefined; const actionId = randomUUID(); const result = await runImmediateGitAction.run({ actionId, action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), }); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { return; } const error = squashAtomCommandFailure(result); const errorToastTiming = resolveGitActionResultToastTiming("error"); toastManager.add( stackedThreadToast({ type: "error", title: "Action failed", description: error instanceof Error ? error.message : "An error occurred.", timeout: errorToastTiming.timeout, ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), }), ); return; } const actionResult = result.value; syncThreadBranchAfterGitAction(actionResult); if (isPanel) { setInlineSuccess({ title: actionResult.toast.title, description: actionResult.toast.description ?? null, scopeKey: successScopeKey, }); return; } let resultToastId: GitActionToastId | null = null; const closeResultToast = () => { if (resultToastId !== null) { toastManager.close(resultToastId); } }; const toastCta = actionResult.toast.cta; let toastActionProps: { children: string; onClick: (event: MouseEvent) => void; } | null = null; if (toastCta.kind === "run_action") { toastActionProps = { children: toastCta.label, onClick: () => { closeResultToast(); void runGitActionWithToast({ action: toastCta.action.kind, }); }, }; } else if (toastCta.kind === "open_pr") { toastActionProps = { children: toastCta.label, onClick: (event) => { closeResultToast(); openPrLink(event, toastCta.url); }, }; } const successToastTiming = resolveGitActionResultToastTiming("success"); const successToastData = { ...scopedToastData, ...(successToastTiming.dismissAfterVisibleMs !== null ? { dismissAfterVisibleMs: successToastTiming.dismissAfterVisibleMs } : {}), }; if (toastActionProps) { resultToastId = toastManager.add( stackedThreadToast({ type: "success", title: actionResult.toast.title, description: actionResult.toast.description, timeout: successToastTiming.timeout, actionProps: toastActionProps, data: successToastData, }), ); } else { resultToastId = toastManager.add({ type: "success", title: actionResult.toast.title, description: actionResult.toast.description, timeout: successToastTiming.timeout, data: successToastData, }); } }, ); const continuePendingDefaultBranchAction = () => { if (!pendingDefaultBranchAction) return; const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), skipDefaultBranchPrompt: true, }); }; const checkoutFeatureBranchAndContinuePendingAction = () => { if (!pendingDefaultBranchAction) return; const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), featureBranch: true, skipDefaultBranchPrompt: true, }); }; const runDialogActionOnNewBranch = () => { if (!isCommitDialogOpen) return; const commitMessage = dialogCommitMessage.trim(); setIsCommitDialogOpen(false); setDialogCommitMessage(""); setExcludedFiles(new Set()); setIsEditingFiles(false); void runGitActionWithToast({ action: "commit", ...(commitMessage ? { commitMessage } : {}), ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), featureBranch: true, skipDefaultBranchPrompt: true, }); }; const runQuickAction = () => { if (quickAction.kind === "open_publish") { setIsPublishDialogOpen(true); return; } if (quickAction.kind === "run_pull") { void (async () => { setInlineSuccess(null); const result = await pullAction.run(); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { return; } const error = squashAtomCommandFailure(result); const errorToastTiming = resolveGitActionResultToastTiming("error"); toastManager.add( stackedThreadToast({ type: "error", title: "Pull failed", description: error instanceof Error ? error.message : "An error occurred.", timeout: errorToastTiming.timeout, ...(threadToastData !== undefined ? { data: threadToastData } : {}), }), ); return; } const pullResult = result.value; const title = pullResult.status === "pulled" ? "Pulled" : "Already up to date"; const description = pullResult.status === "pulled" ? `Updated ${pullResult.refName} from ${pullResult.upstreamRef ?? "upstream"}` : `${pullResult.refName} is already synchronized.`; if (isPanel) { setInlineSuccess({ title, description, scopeKey: successScopeKey }); return; } const successToastTiming = resolveGitActionResultToastTiming("success"); toastManager.add({ type: "success", title, description, timeout: successToastTiming.timeout, data: { ...threadToastData, ...(successToastTiming.dismissAfterVisibleMs !== null ? { dismissAfterVisibleMs: successToastTiming.dismissAfterVisibleMs } : {}), }, }); })(); return; } if (quickAction.kind === "show_hint") { toastManager.add({ type: "info", title: quickAction.label, description: quickAction.hint, data: threadToastData, }); return; } if (quickAction.action) { void runGitActionWithToast({ action: quickAction.action }); } }; const openDialogForMenuItem = (item: GitActionMenuItem) => { if (item.disabled) return; if (item.dialogAction === "push") { void runGitActionWithToast({ action: "push" }); return; } if (item.dialogAction === "create_pr") { void runGitActionWithToast({ action: "create_pr" }); return; } setExcludedFiles(new Set()); setIsEditingFiles(false); setIsCommitDialogOpen(true); }; const runDialogAction = () => { if (!isCommitDialogOpen) return; const commitMessage = dialogCommitMessage.trim(); setIsCommitDialogOpen(false); setDialogCommitMessage(""); setExcludedFiles(new Set()); setIsEditingFiles(false); void runGitActionWithToast({ action: "commit", ...(commitMessage ? { commitMessage } : {}), ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), }); }; const openChangedFileInEditor = useCallback( (filePath: string) => { if (!gitCwd) { toastManager.add({ type: "error", title: "Editor opening is unavailable.", data: threadToastData, }); return; } const target = resolvePathLinkTarget(filePath, gitCwd); void (async () => { const result = await openInPreferredEditor(target); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open file", description: error instanceof Error ? error.message : "An error occurred.", ...(threadToastData !== undefined ? { data: threadToastData } : {}), }), ); })(); }, [gitCwd, openInPreferredEditor, threadToastData], ); const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; if (!gitCwd) return null; return ( <> {!isRepo ? ( ) : ( {gitActionProgress ? ( ) : isPanel && visibleInlineSuccess ? ( ) : quickActionDisabledReason ? ( } > {quickAction.label} {quickActionDisabledReason} ) : ( )} {isPanel && gitActionProgress ? ( // The menu is disabled while an action runs, so its chevron slot // hosts the elapsed counter instead, leaving the full row width to // the status text. Pinned to the title row so it stays put when // the output row expands below. ) : ( <> {isPanel ? ( )} {isPanel && isRepo ? ( ) : null} { if (!open) { setIsCommitDialogOpen(false); setDialogCommitMessage(""); setExcludedFiles(new Set()); setIsEditingFiles(false); } }} > {COMMIT_DIALOG_TITLE} {COMMIT_DIALOG_DESCRIPTION}
Branch {gitStatusForActions?.refName ?? "(detached HEAD)"} {isDefaultRef && ( Warning: default refName )}
{isEditingFiles && allFiles.length > 0 && ( { setExcludedFiles( allSelected ? new Set(allFiles.map((f) => f.path)) : new Set(), ); }} /> )} Files {!allSelected && !isEditingFiles && ( ({selectedFiles.length} of {allFiles.length}) )}
{allFiles.length > 0 && ( )}
{!gitStatusForActions || allFiles.length === 0 ? (

none

) : (
{allFiles.map((file) => { const isExcluded = excludedFiles.has(file.path); return (
{isEditingFiles && ( { setExcludedFiles((prev) => { const next = new Set(prev); if (next.has(file.path)) { next.delete(file.path); } else { next.add(file.path); } return next; }); }} /> )}
); })}
+{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
)}

Commit message (optional)