2153 lines · 81.2 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 {  GitRunStackedActionResult,  GitStackedAction,  SourceControlCloneProtocol,  SourceControlProviderDiscoveryItem,  SourceControlProviderKind,  SourceControlPublishRepositoryResult,  SourceControlRepositoryVisibility,  VcsStatusResult,} from "@t3tools/contracts";import { useNavigate } from "@tanstack/react-router";import * as Option from "effect/Option";import {  type MouseEvent,  useCallback,  useEffect,  useEffectEvent,  useMemo,  useRef,  useState,} from "react";import { flushSync } from "react-dom";import {  CheckIcon,  ChevronDownIcon,  CloudDownloadIcon,  CloudUploadIcon,  FileDiffIcon,  GitBranchPlusIcon,  GitCommitIcon,  InfoIcon,  LockIcon,  GlobeIcon,} from "lucide-react";import { Radio as RadioPrimitive } from "@base-ui/react/radio";import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons";import { RadioGroup } from "~/components/ui/radio-group";import { Spinner } from "~/components/ui/spinner";import { toggleVariants } from "~/components/ui/toggle";import { cn } from "~/lib/utils";import { useOpenPrLink } from "~/lib/openPullRequestLink";import {  buildMenuItems,  formatGitActionElapsed,  GIT_ACTION_SUCCESS_VISIBLE_MS,  type GitActionProgressPresentation,  type GitActionIconName,  type GitActionMenuItem,  type GitQuickAction,  type DefaultBranchConfirmableAction,  requiresDefaultBranchConfirmation,  resolveDefaultBranchActionDialogCopy,  resolveGitActionProgressPresentation,  resolveGitActionResultToastTiming,  resolveLiveThreadBranchUpdate,  resolveThreadBranchMetadataPatch,  resolveQuickAction,  resolveThreadBranchUpdate,} from "./GitActionsControl.logic";import { AnimatedHeight } from "./AnimatedHeight";import { StartTruncatedPath } from "./StartTruncatedPath";import { Button } from "~/components/ui/button";import { Checkbox } from "~/components/ui/checkbox";import {  Dialog,  DialogDescription,  DialogFooter,  DialogHeader,  DialogPanel,  DialogPopup,  DialogTitle,} from "~/components/ui/dialog";import { Group, GroupSeparator } from "~/components/ui/group";import { Input } from "~/components/ui/input";import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";import { ScrollArea } from "~/components/ui/scroll-area";import { Textarea } from "~/components/ui/textarea";import { stackedThreadToast, toastManager } from "~/components/ui/toast";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";import { useOpenInPreferredEditor } from "~/editorPreferences";import {  useGitStackedAction,  useSourceControlActionRunning,  useSourceControlPublishRepositoryAction,  useVcsInitAction,  useVcsPullAction,} from "~/lib/sourceControlActions";import { useThreadShell } from "~/state/entities";import { useEnvironmentQuery } from "~/state/query";import { serverEnvironment } from "~/state/server";import { sourceControlEnvironment } from "~/state/sourceControl";import { threadEnvironment } from "~/state/threads";import { useAtomCommand } from "~/state/use-atom-command";import { vcsActionManager, vcsEnvironment } from "~/state/vcs";import { randomUUID } from "~/lib/utils";import { resolvePathLinkTarget } from "~/terminal-links";import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";import {  THREAD_DETAILS_PANEL_CHEVRON_CLASS,  THREAD_DETAILS_PANEL_ICON_CLASS,  THREAD_DETAILS_PANEL_ROW_POPUP_CLASS,  THREAD_DETAILS_PANEL_ROW_CLASS,  THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,  THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS,  THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS,  THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,} from "./chat/threadDetailsPanelStyles";import { getSourceControlPresentation } from "~/sourceControlPresentation";import { useOpenLink } from "~/browser/useOpenLink"; interface GitActionsControlProps {  gitCwd: string | null;  activeThreadRef: ScopedThreadRef | null;  draftId?: DraftId;  /**   * Opens the thread's own change request beside it. Absent when the thread has no project to   * place it against, in which case it still opens in the browser.   */  onOpenPullRequest?: ((number: number) => void) | undefined;  displayMode?: "toolbar" | "panel";  onOpenChanges?: () => void;} interface PendingDefaultBranchAction {  action: DefaultBranchConfirmableAction;  branchName: string;  includesCommit: boolean;  commitMessage?: string;  onConfirmed?: () => void;  filePaths?: string[];} type PublishProviderKind = Extract<  SourceControlProviderKind,  "github" | "gitlab" | "bitbucket" | "azure-devops">; type GitActionToastId = ReturnType<typeof toastManager.add>; interface RunGitActionWithToastInput {  action: GitStackedAction;  commitMessage?: string;  onConfirmed?: () => void;  skipDefaultBranchPrompt?: boolean;  statusOverride?: VcsStatusResult | null;  featureBranch?: boolean;  filePaths?: string[];} interface InlineGitActionSuccess {  readonly title: string;  readonly description: string | null;  readonly scopeKey: string;} const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; type RefreshVcsStatus = (target: {  readonly environmentId: ScopedThreadRef["environmentId"];  readonly input: { readonly cwd: string };}) => Promise<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 getMenuActionDisabledReason({  item,  gitStatus,  isBusy,  hasPrimaryRemote,}: {  item: GitActionMenuItem;  gitStatus: VcsStatusResult | null;  isBusy: boolean;  hasPrimaryRemote: boolean;}): string | null {  if (!item.disabled) return null;  if (isBusy) return "Git action in progress.";  if (!gitStatus) return "Git status is unavailable.";   const hasBranch = gitStatus.refName !== null;  const hasChanges = gitStatus.hasWorkingTreeChanges;  const isAhead = gitStatus.aheadCount > 0;  const isBehind = gitStatus.behindCount > 0;  const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology;   if (item.id === "commit") {    if (!hasChanges) {      return "Worktree is clean. Make changes before committing.";    }    return "Commit is currently unavailable.";  }   if (item.id === "push") {    if (!hasBranch) {      return "Detached HEAD: checkout a refName before pushing.";    }    if (hasChanges) {      return "Commit or stash local changes before pushing.";    }    if (isBehind) {      return "Branch is behind upstream. Pull/rebase before pushing.";    }    if (!gitStatus.hasUpstream && !hasPrimaryRemote) {      return 'Add an "origin" remote before pushing.';    }    if (!isAhead) {      return "No local commits to push.";    }    return "Push is currently unavailable.";  }   if (!hasBranch) {    return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`;  }  if (hasChanges) {    return `Commit local changes before creating a ${terminology.singular}.`;  }  if (!gitStatus.hasUpstream && !hasPrimaryRemote) {    return `Add an "origin" remote before creating a ${terminology.singular}.`;  }  if (!isAhead) {    return `No local commits to include in a ${terminology.singular}.`;  }  if (isBehind) {    return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`;  }  return `Create ${terminology.singular} is currently unavailable.`;} const COMMIT_DIALOG_TITLE = "Commit changes";const COMMIT_DIALOG_DESCRIPTION =  "Review and confirm your commit. Leave the message blank to auto-generate one."; function GitActionItemIcon({  icon,  SourceControlIcon,}: {  icon: GitActionIconName;  SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];}) {  if (icon === "commit") return <GitCommitIcon />;  if (icon === "push") return <CloudUploadIcon />;  return <SourceControlIcon />;} function GitQuickActionIcon({  quickAction,  SourceControlIcon,  className = "size-3.5",}: {  quickAction: GitQuickAction;  SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];  className?: string;}) {  const iconClassName = className;  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} />;} function GitActionElapsedTime({  startedAtMs,  className,}: {  startedAtMs: number | null;  className?: string;}) {  const [nowMs, setNowMs] = useState(() => Date.now());   useEffect(() => {    if (startedAtMs === null) {      return;    }     const intervalId = window.setInterval(() => {      setNowMs(Date.now());    }, 1_000);    return () => window.clearInterval(intervalId);  }, [startedAtMs]);   const elapsed = formatGitActionElapsed(startedAtMs, nowMs);  if (!elapsed) {    return null;  }   return <p className={className}>{elapsed}</p>;} function GitActionProgressButtonContent({  progress,  isPanel,}: {  progress: GitActionProgressPresentation;  isPanel: boolean;}) {  const hasOutput = progress.output !== null;   return (    <div      aria-atomic="false"      aria-live="polite"      className={cn(        "grid min-w-0 flex-1 items-center",        // Pin the title row to the button's minimum content height (min-height        // minus vertical padding and border) so revealing the output row        // extends the button downward without re-centering — the title must        // not shift. No row gap: the collapsed output row must contribute zero        // height so the single-line running button matches the static button        // exactly. The panel column gap matches the static row's icon-to-label        // distance (gap-2.5 plus the label's ml-0.5). In the panel the elapsed        // counter renders outside the button (in the menu-chevron slot), so        // there is no trailing column.        isPanel          ? "grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.75rem] gap-x-3"          : "grid-cols-[auto_minmax(0,1fr)_auto] grid-rows-[1.25rem] gap-x-2.5 sm:grid-rows-[1rem]",      )}      role="status"    >      <Spinner        aria-hidden="true"        className="row-start-1 -mx-0.5 size-4 shrink-0 text-muted-foreground"      />      <p className="row-start-1 min-w-0 truncate text-left">{progress.status}</p>      {!isPanel ? (        <GitActionElapsedTime          startedAtMs={progress.startedAtMs}          className="row-start-1 text-[11px] font-normal tabular-nums text-muted-foreground"        />      ) : null}      <div        className={cn(          "col-start-2 grid min-w-0 transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none",          !isPanel && "col-span-2",          hasOutput ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",        )}      >        <div className="min-h-0 overflow-hidden">          <Tooltip>            <TooltipTrigger              render={                <p className="truncate pt-0.5 text-left text-[11px] font-normal text-muted-foreground" />              }            >              {progress.output}            </TooltipTrigger>            <TooltipPopup side="bottom" className="max-w-96 break-words">              {progress.output}            </TooltipPopup>          </Tooltip>        </div>      </div>    </div>  );} function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSuccess }) {  const hasDescription = success.description !== null;   return (    <div      aria-live="polite"      className="grid min-w-0 flex-1 grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.75rem] items-center gap-x-3"      role="status"    >      <CheckIcon aria-hidden="true" className="size-3.5 shrink-0 text-success" />      <p className="min-w-0 truncate text-left">{success.title}</p>      <div        className={cn(          "col-start-2 grid min-w-0 transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none",          hasDescription ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",        )}      >        <div className="min-h-0 overflow-hidden">          <Tooltip>            <TooltipTrigger              render={                <p className="truncate pt-0.5 text-left text-[11px] font-normal text-muted-foreground" />              }            >              {success.description}            </TooltipTrigger>            <TooltipPopup side="bottom" className="max-w-96 break-words">              {success.description}            </TooltipPopup>          </Tooltip>        </div>      </div>    </div>  );} interface PublishRepositoryDialogProps {

Lines 1–500 of 2153.