import { type ScopedThreadRef } from "@t3tools/contracts";import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, SourceControlCloneProtocol, SourceControlProviderDiscoveryItem, SourceControlProviderKind, SourceControlPublishRepositoryResult, SourceControlRepositoryVisibility, VcsStatusResult,} from "@t3tools/contracts";import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";import { useNavigate } from "@tanstack/react-router";import * as Option from "effect/Option";import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";import { flushSync } from "react-dom";import { CheckIcon, ChevronDownIcon, CloudUploadIcon, ExternalLinkIcon, 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 { cn } from "~/lib/utils";import { buildGitActionProgressStages, buildMenuItems, type GitActionIconName, type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveQuickAction, resolveThreadBranchUpdate,} from "./GitActionsControl.logic";import { AnimatedHeight } from "./AnimatedHeight";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, type ThreadToastData } from "~/components/ui/toast";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";import { openInPreferredEditor } from "~/editorPreferences";import { gitInitMutationOptions, gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, sourceControlPublishRepositoryMutationOptions,} from "~/lib/gitReactQuery";import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState";import { newCommandId, randomUUID } from "~/lib/utils";import { resolvePathLinkTarget } from "~/terminal-links";import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";import { readEnvironmentApi } from "~/environmentApi";import { readLocalApi } from "~/localApi";import { getSourceControlPresentation } from "~/sourceControlPresentation";import { useStore } from "~/store";import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId;} 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<typeof toastManager.add>; interface ActiveGitActionProgress { toastId: GitActionToastId; toastData: ThreadToastData | undefined; actionId: string; title: string; phaseStartedAtMs: number | null; hookStartedAtMs: number | null; hookName: string | null; lastOutputLine: string | null; currentPhaseLabel: string | null;} interface RunGitActionWithToastInput { action: GitStackedAction; commitMessage?: string; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; statusOverride?: VcsStatusResult | null; featureBranch?: boolean; progressToastId?: GitActionToastId; filePaths?: string[];} const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; 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<SourceControlProviderDiscoveryItem>;}): { 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 formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; } const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); if (elapsedSeconds < 60) { return `Running for ${elapsedSeconds}s`; } const minutes = Math.floor(elapsedSeconds / 60); const seconds = elapsedSeconds % 60; return `Running for ${minutes}m ${seconds}s`;} function resolveProgressDescription(progress: ActiveGitActionProgress): string | undefined { if (progress.lastOutputLine) { return progress.lastOutputLine; } return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs);} 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 hasOpenPr = gitStatus.pr?.state === "open"; 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 (hasOpenPr) { return `View ${terminology.singular} 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<typeof getSourceControlPresentation>["Icon"];}) { if (icon === "commit") return <GitCommitIcon />; if (icon === "push") return <CloudUploadIcon />; return <SourceControlIcon />;} function GitQuickActionIcon({ quickAction, SourceControlIcon,}: { quickAction: GitQuickAction; SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];}) { const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />; if (quickAction.kind === "open_publish") return <CloudUploadIcon className={iconClassName} />; if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; if (quickAction.action === "push" || quickAction.action === "commit_push") { return <CloudUploadIcon className={iconClassName} />; } return <SourceControlIcon className={iconClassName} />; } if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />; return <InfoIcon className={iconClassName} />;} interface PublishRepositoryDialogProps { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: ScopedThreadRef["environmentId"] | null; readonly gitCwd: string;} function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const queryClient = useQueryClient(); const navigate = useNavigate(); const sourceControlDiscovery = useSourceControlDiscovery(); const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github"); const [publishRepository, setPublishRepository] = useState(""); const [publishVisibility, setPublishVisibility] = useState<SourceControlRepositoryVisibility>("private"); const [publishRemoteName, setPublishRemoteName] = useState("origin"); const [publishProtocol, setPublishProtocol] = useState<SourceControlCloneProtocol>("ssh"); const [publishWizardStep, setPublishWizardStep] = useState(0); const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); const [publishError, setPublishError] = useState<string | null>(null); const [publishResult, setPublishResult] = useState<SourceControlPublishRepositoryResult | null>( null, ); const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); const publishRepositoryMutation = useMutation( sourceControlPublishRepositoryMutationOptions({ environmentId: props.environmentId, cwd: props.gitCwd, queryClient, }), ); const publishAccountByProvider = useMemo(() => { const accounts: Record<PublishProviderKind, string | null> = { 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<PublishProviderKind, { readonly ready: boolean; readonly hint: string | null }>; }, [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 selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; const publishRepositoryPrefill = publishAccountByProvider[publishProvider] ? `${publishAccountByProvider[publishProvider]}/` : ""; 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; useEffect(() => { if (!props.open || hasUserEditedPublishRepository) { return; } setPublishRepository(publishRepositoryPrefill); }, [hasUserEditedPublishRepository, props.open, publishRepositoryPrefill]); const canSubmitPublishRepository = useMemo(() => { if (!selectedPublishProviderReadiness.ready) return false; if (publishRepositoryMutation.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, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]); useEffect(() => { if (!props.open) { return; } if (publishProviderReadiness[publishProvider].ready) { return; } const firstReadyProvider = PUBLISH_PROVIDER_OPTIONS.find( (option) => publishProviderReadiness[option.value].ready, ); if (firstReadyProvider) { setPublishProvider(firstReadyProvider.value); } }, [props.open, publishProvider, publishProviderReadiness]); const submitPublishRepository = useCallback(() => { if (!canSubmitPublishRepository) { return; } setPublishError(null); void publishRepositoryMutation .mutateAsync({ provider: publishProvider, repository: publishRepository.trim(), visibility: publishVisibility, remoteName: publishRemoteName.trim() || "origin", protocol: publishProtocol, }) .then((result) => { flushSync(() => { setPublishResult(result); setPublishWizardStep(2); }); void refreshGitStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( () => undefined, ); }) .catch((err: unknown) => { setPublishError(err instanceof Error ? err.message : "An error occurred."); }); }, [ canSubmitPublishRepository, props.environmentId, props.gitCwd, publishProtocol, publishProvider, publishRemoteName, publishRepository, publishRepositoryMutation, publishVisibility, ]); const resetState = useCallback(() => {Lines 1–500 of 1986. Load more
import { type ScopedThreadRef } from "@t3tools/contracts"; import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, SourceControlCloneProtocol, SourceControlProviderDiscoveryItem, SourceControlProviderKind, SourceControlPublishRepositoryResult, SourceControlRepositoryVisibility, VcsStatusResult, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { CheckIcon, ChevronDownIcon, CloudUploadIcon, ExternalLinkIcon, 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 { cn } from "~/lib/utils"; import { buildGitActionProgressStages, buildMenuItems, type GitActionIconName, type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { AnimatedHeight } from "./AnimatedHeight"; 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, type ThreadToastData } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { openInPreferredEditor } from "~/editorPreferences"; import { gitInitMutationOptions, gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, sourceControlPublishRepositoryMutationOptions, } from "~/lib/gitReactQuery"; import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useStore } from "~/store"; import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId; } 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<typeof toastManager.add>; interface ActiveGitActionProgress { toastId: GitActionToastId; toastData: ThreadToastData | undefined; actionId: string; title: string; phaseStartedAtMs: number | null; hookStartedAtMs: number | null; hookName: string | null; lastOutputLine: string | null; currentPhaseLabel: string | null; } interface RunGitActionWithToastInput { action: GitStackedAction; commitMessage?: string; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; statusOverride?: VcsStatusResult | null; featureBranch?: boolean; progressToastId?: GitActionToastId; filePaths?: string[]; } const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; 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<SourceControlProviderDiscoveryItem>; }): { 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 formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; } const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); if (elapsedSeconds < 60) { return `Running for ${elapsedSeconds}s`; } const minutes = Math.floor(elapsedSeconds / 60); const seconds = elapsedSeconds % 60; return `Running for ${minutes}m ${seconds}s`; } function resolveProgressDescription(progress: ActiveGitActionProgress): string | undefined { if (progress.lastOutputLine) { return progress.lastOutputLine; } return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs); } 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 hasOpenPr = gitStatus.pr?.state === "open"; 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 (hasOpenPr) { return `View ${terminology.singular} 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<typeof getSourceControlPresentation>["Icon"]; }) { if (icon === "commit") return <GitCommitIcon />; if (icon === "push") return <CloudUploadIcon />; return <SourceControlIcon />; } function GitQuickActionIcon({ quickAction, SourceControlIcon, }: { quickAction: GitQuickAction; SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"]; }) { const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />; if (quickAction.kind === "open_publish") return <CloudUploadIcon className={iconClassName} />; if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; if (quickAction.action === "push" || quickAction.action === "commit_push") { return <CloudUploadIcon className={iconClassName} />; } return <SourceControlIcon className={iconClassName} />; } if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />; return <InfoIcon className={iconClassName} />; } interface PublishRepositoryDialogProps { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: ScopedThreadRef["environmentId"] | null; readonly gitCwd: string; } function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const queryClient = useQueryClient(); const navigate = useNavigate(); const sourceControlDiscovery = useSourceControlDiscovery(); const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github"); const [publishRepository, setPublishRepository] = useState(""); const [publishVisibility, setPublishVisibility] = useState<SourceControlRepositoryVisibility>("private"); const [publishRemoteName, setPublishRemoteName] = useState("origin"); const [publishProtocol, setPublishProtocol] = useState<SourceControlCloneProtocol>("ssh"); const [publishWizardStep, setPublishWizardStep] = useState(0); const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); const [publishError, setPublishError] = useState<string | null>(null); const [publishResult, setPublishResult] = useState<SourceControlPublishRepositoryResult | null>( null, ); const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); const publishRepositoryMutation = useMutation( sourceControlPublishRepositoryMutationOptions({ environmentId: props.environmentId, cwd: props.gitCwd, queryClient, }), ); const publishAccountByProvider = useMemo(() => { const accounts: Record<PublishProviderKind, string | null> = { 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<PublishProviderKind, { readonly ready: boolean; readonly hint: string | null }>; }, [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 selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; const publishRepositoryPrefill = publishAccountByProvider[publishProvider] ? `${publishAccountByProvider[publishProvider]}/` : ""; 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; useEffect(() => { if (!props.open || hasUserEditedPublishRepository) { return; } setPublishRepository(publishRepositoryPrefill); }, [hasUserEditedPublishRepository, props.open, publishRepositoryPrefill]); const canSubmitPublishRepository = useMemo(() => { if (!selectedPublishProviderReadiness.ready) return false; if (publishRepositoryMutation.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, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]); useEffect(() => { if (!props.open) { return; } if (publishProviderReadiness[publishProvider].ready) { return; } const firstReadyProvider = PUBLISH_PROVIDER_OPTIONS.find( (option) => publishProviderReadiness[option.value].ready, ); if (firstReadyProvider) { setPublishProvider(firstReadyProvider.value); } }, [props.open, publishProvider, publishProviderReadiness]); const submitPublishRepository = useCallback(() => { if (!canSubmitPublishRepository) { return; } setPublishError(null); void publishRepositoryMutation .mutateAsync({ provider: publishProvider, repository: publishRepository.trim(), visibility: publishVisibility, remoteName: publishRemoteName.trim() || "origin", protocol: publishProtocol, }) .then((result) => { flushSync(() => { setPublishResult(result); setPublishWizardStep(2); }); void refreshGitStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( () => undefined, ); }) .catch((err: unknown) => { setPublishError(err instanceof Error ? err.message : "An error occurred."); }); }, [ canSubmitPublishRepository, props.environmentId, props.gitCwd, publishProtocol, publishProvider, publishRemoteName, publishRepository, publishRepositoryMutation, publishVisibility, ]); const resetState = useCallback(() => {