1903 lines · 70.6 KB · TypeScript
RawDownload
import { type ScopedThreadRef } from "@t3tools/contracts";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 { 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,  getGitActionDisabledReason,  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 {  useGitStackedAction,  useSourceControlActionRunning,  useSourceControlPublishRepositoryAction,  useVcsInitAction,  useVcsPullAction,} from "~/lib/sourceControlActions";import { refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState";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 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);} 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 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 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 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 (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]);   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 publishRepositoryAction      .run({        provider: publishProvider,        repository: publishRepository.trim(),        visibility: publishVisibility,        remoteName: publishRemoteName.trim() || "origin",        protocol: publishProtocol,      })      .then((result) => {        flushSync(() => {          setPublishResult(result);          setPublishWizardStep(2);        });        void refreshVcsStatus({ 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,    publishRepositoryAction,    publishVisibility,  ]);   const resetState = useCallback(() => {    setPublishRemoteName("origin");    setPublishRepository("");    setHasUserEditedPublishRepository(false);    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 (    <Dialog open={props.open} onOpenChange={handleOpenChange}>      <DialogPopup className="max-w-xl overflow-hidden">        <div className="flex min-h-0 flex-col overflow-hidden border-foreground/10 bg-background shadow-2xl">          <DialogHeader className="border-b border-border/70 bg-background">            <DialogTitle>Publish repository</DialogTitle>            <DialogDescription>              Pick where to host it, then point us at a repo to push to.            </DialogDescription>            <div className="grid grid-cols-3 gap-2">              {publishWizardSteps.map((label, index) => {                const isComplete = index < publishWizardStep;                const isClickable =                  publishWizardStep !== 2 &&                  index < publishWizardSteps.length - 1 &&                  index <= publishWizardStep;                return (                  <button                    key={label}                    type="button"                    onClick={isClickable ? () => setPublishWizardStep(index) : undefined}                    disabled={!isClickable}                    className={cn(                      "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left",                      index === publishWizardStep                        ? "border-primary bg-primary/10 ring-1 ring-primary/25"                        : isComplete                          ? "border-border bg-background"                          : "border-border bg-muted/40",                      !isClickable && "cursor-default",                    )}                  >                    <span                      aria-hidden                      className={cn(                        "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border",                        isComplete                          ? "border-primary bg-primary text-primary-foreground"                          : index === publishWizardStep                            ? "border-primary bg-background"                            : "border-muted-foreground/35 bg-background",                      )}                    >                      {isComplete ? <CheckIcon className="size-3" /> : null}

Lines 1–500 of 1903.