import { useAtomValue } from "@effect/atom-react";import { type ScopedThreadRef } from "@t3tools/contracts";import { isAtomCommandInterrupted, squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import type { GitActionProgressEvent, 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, 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 { buildGitActionProgressStages, buildMenuItems, type GitActionIconName, type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveThreadBranchMetadataPatch, resolveQuickAction, resolveThreadBranchUpdate,} from "./GitActionsControl.logic";import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard";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, type ThreadToastData } 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 { vcsEnvironment } from "~/state/vcs";import { randomUUID } from "~/lib/utils";import { resolvePathLinkTarget } from "~/terminal-links";import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";import { getSourceControlPresentation } from "~/sourceControlPresentation";import { useOpenLink } from "~/browser/useOpenLink";import { useOpenPrLink } from "~/lib/openPullRequestLink"; 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;} 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; type RefreshVcsStatus = (target: { readonly environmentId: ScopedThreadRef["environmentId"]; readonly input: { readonly cwd: string };}) => Promise<unknown>; 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<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 <CloudDownloadIcon 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} />; if (quickAction.label === "Push") return <CloudUploadIcon className={iconClassName} />; return <InfoIcon className={iconClassName} />;} 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<PublishProviderKind | null>(null); const [publishRepositoryOverride, setPublishRepositoryOverride] = useState<string | null>(null); 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 sourceControlScope = useMemo( () => ({ environmentId: props.environmentId, cwd: props.gitCwd, }), [props.environmentId, props.gitCwd], ); const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); 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 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) {Lines 1–500 of 1996. Load more
import { useAtomValue } from "@effect/atom-react"; import { type ScopedThreadRef } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import type { GitActionProgressEvent, 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, 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 { buildGitActionProgressStages, buildMenuItems, type GitActionIconName, type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveThreadBranchMetadataPatch, resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard"; 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, type ThreadToastData } 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 { vcsEnvironment } from "~/state/vcs"; import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useOpenLink } from "~/browser/useOpenLink"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; 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; } 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; type RefreshVcsStatus = (target: { readonly environmentId: ScopedThreadRef["environmentId"]; readonly input: { readonly cwd: string }; }) => Promise<unknown>; 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<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 <CloudDownloadIcon 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} />; if (quickAction.label === "Push") return <CloudUploadIcon className={iconClassName} />; return <InfoIcon className={iconClassName} />; } 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<PublishProviderKind | null>(null); const [publishRepositoryOverride, setPublishRepositoryOverride] = useState<string | null>(null); 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 sourceControlScope = useMemo( () => ({ environmentId: props.environmentId, cwd: props.gitCwd, }), [props.environmentId, props.gitCwd], ); const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); 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 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) {