1996 lines · 72.5 KB · TypeScript
RawDownload
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.