1191 lines · 40.7 KB · TypeScript
RawDownload
import { type ScopedThreadRef } from "@t3tools/contracts";import type {  GitActionProgressEvent,  GitRunStackedActionResult,  GitStackedAction,  GitStatusResult,} from "@t3tools/contracts";import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";import { GitHubIcon } from "./Icons";import {  buildGitActionProgressStages,  buildMenuItems,  type GitActionIconName,  type GitActionMenuItem,  type GitQuickAction,  type DefaultBranchConfirmableAction,  requiresDefaultBranchConfirmation,  resolveDefaultBranchActionDialogCopy,  resolveLiveThreadBranchUpdate,  resolveQuickAction,  resolveThreadBranchUpdate,} from "./GitActionsControl.logic";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 { 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 { openInPreferredEditor } from "~/editorPreferences";import {  gitInitMutationOptions,  gitMutationKeys,  gitPullMutationOptions,  gitRunStackedActionMutationOptions,} from "~/lib/gitReactQuery";import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";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 { 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 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?: GitStatusResult | null;  featureBranch?: boolean;  progressToastId?: GitActionToastId;  filePaths?: string[];} const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; 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,  hasOriginRemote,}: {  item: GitActionMenuItem;  gitStatus: GitStatusResult | null;  isBusy: boolean;  hasOriginRemote: 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.branch !== null;  const hasChanges = gitStatus.hasWorkingTreeChanges;  const hasOpenPr = gitStatus.pr?.state === "open";  const isAhead = gitStatus.aheadCount > 0;  const isBehind = gitStatus.behindCount > 0;   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 branch 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 && !hasOriginRemote) {      return 'Add an "origin" remote before pushing.';    }    if (!isAhead) {      return "No local commits to push.";    }    return "Push is currently unavailable.";  }   if (hasOpenPr) {    return "View PR is currently unavailable.";  }  if (!hasBranch) {    return "Detached HEAD: checkout a branch before creating a PR.";  }  if (hasChanges) {    return "Commit local changes before creating a PR.";  }  if (!gitStatus.hasUpstream && !hasOriginRemote) {    return 'Add an "origin" remote before creating a PR.';  }  if (!isAhead) {    return "No local commits to include in a PR.";  }  if (isBehind) {    return "Branch is behind upstream. Pull/rebase before creating a PR.";  }  return "Create PR 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 }: { icon: GitActionIconName }) {  if (icon === "commit") return <GitCommitIcon />;  if (icon === "push") return <CloudUploadIcon />;  return <GitHubIcon />;} function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {  const iconClassName = "size-3.5";  if (quickAction.kind === "open_pr") return <GitHubIcon 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 <GitHubIcon className={iconClassName} />;  }  if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />;  return <InfoIcon className={iconClassName} />;} export default function GitActionsControl({  gitCwd,  activeThreadRef,  draftId,}: GitActionsControlProps) {  const activeEnvironmentId = activeThreadRef?.environmentId ?? null;  const threadToastData = useMemo(    () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),    [activeThreadRef],  );  const activeServerThreadSelector = useMemo(    () => createThreadSelectorByRef(activeThreadRef),    [activeThreadRef],  );  const activeServerThread = useStore(activeServerThreadSelector);  const activeDraftThread = useComposerDraftStore((store) =>    draftId      ? store.getDraftSession(draftId)      : activeThreadRef        ? store.getDraftThreadByRef(activeThreadRef)        : null,  );  const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);  const setThreadBranch = useStore((store) => store.setThreadBranch);  const queryClient = useQueryClient();  const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);  const [dialogCommitMessage, setDialogCommitMessage] = useState("");  const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());  const [isEditingFiles, setIsEditingFiles] = useState(false);  const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =    useState<PendingDefaultBranchAction | null>(null);  const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null);  let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>;   const updateActiveProgressToast = useCallback(() => {    const progress = activeGitActionProgressRef.current;    if (!progress) {      return;    }    toastManager.update(progress.toastId, {      type: "loading",      title: progress.title,      description: resolveProgressDescription(progress),      timeout: 0,      data: progress.toastData,    });  }, []);   const persistThreadBranchSync = useCallback(    (branch: string | null) => {      if (!activeThreadRef) {        return;      }       if (activeServerThread) {        if (activeServerThread.branch === branch) {          return;        }         const worktreePath = activeServerThread.worktreePath;        const api = readEnvironmentApi(activeThreadRef.environmentId);        if (api) {          void api.orchestration            .dispatchCommand({              type: "thread.meta.update",              commandId: newCommandId(),              threadId: activeThreadRef.threadId,              branch,              worktreePath,            })            .catch(() => undefined);        }         setThreadBranch(activeThreadRef, branch, worktreePath);        return;      }       if (!activeDraftThread || activeDraftThread.branch === branch) {        return;      }       setDraftThreadContext(draftId ?? activeThreadRef, {        branch,        worktreePath: activeDraftThread.worktreePath,      });    },    [      activeDraftThread,      activeServerThread,      activeThreadRef,      draftId,      setDraftThreadContext,      setThreadBranch,    ],  );   const syncThreadBranchAfterGitAction = useCallback(    (result: GitRunStackedActionResult) => {      const branchUpdate = resolveThreadBranchUpdate(result);      if (!branchUpdate) {        return;      }       persistThreadBranchSync(branchUpdate.branch);    },    [persistThreadBranchSync],  );   const { data: gitStatus = null, error: gitStatusError } = useGitStatus({    environmentId: activeEnvironmentId,    cwd: gitCwd,  });  // Default to true while loading so we don't flash init controls.  const isRepo = gitStatus?.isRepo ?? true;  const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;  const gitStatusForActions = gitStatus;   const allFiles = gitStatusForActions?.workingTree.files ?? [];  const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));  const allSelected = excludedFiles.size === 0;  const noneSelected = selectedFiles.length === 0;   const initMutation = useMutation(    gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),  );   const runImmediateGitActionMutation = useMutation(    gitRunStackedActionMutationOptions({      environmentId: activeEnvironmentId,      cwd: gitCwd,      queryClient,    }),  );  const pullMutation = useMutation(    gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),  );   const isRunStackedActionRunning =    useIsMutating({      mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd),    }) > 0;  const isPullRunning =    useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0;  const isGitActionRunning = isRunStackedActionRunning || isPullRunning;  const isSelectingWorktreeBase =    !activeServerThread &&    activeDraftThread?.envMode === "worktree" &&    activeDraftThread.worktreePath === null;   useEffect(() => {    if (isGitActionRunning || isSelectingWorktreeBase) {      return;    }     const branchUpdate = resolveLiveThreadBranchUpdate({      threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null,      gitStatus: gitStatusForActions,    });    if (!branchUpdate) {      return;    }     persistThreadBranchSync(branchUpdate.branch);  }, [    activeServerThread?.branch,    activeDraftThread?.branch,    gitStatusForActions,    isGitActionRunning,    isSelectingWorktreeBase,    persistThreadBranchSync,  ]);   const isDefaultBranch = useMemo(() => {    return gitStatusForActions?.isDefaultBranch ?? false;  }, [gitStatusForActions?.isDefaultBranch]);   const gitActionMenuItems = useMemo(    () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote),    [gitStatusForActions, hasOriginRemote, isGitActionRunning],  );  const quickAction = useMemo(    () =>      resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote),    [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning],  );  const quickActionDisabledReason = quickAction.disabled    ? (quickAction.hint ?? "This action is currently unavailable.")    : null;  const pendingDefaultBranchActionCopy = pendingDefaultBranchAction    ? resolveDefaultBranchActionDialogCopy({        action: pendingDefaultBranchAction.action,        branchName: pendingDefaultBranchAction.branchName,        includesCommit: pendingDefaultBranchAction.includesCommit,      })    : null;   useEffect(() => {    const interval = window.setInterval(() => {      if (!activeGitActionProgressRef.current) {        return;      }      updateActiveProgressToast();    }, 1000);     return () => {      window.clearInterval(interval);    };  }, [updateActiveProgressToast]);   useEffect(() => {    if (gitCwd === null) {      return;    }     let refreshTimeout: number | null = null;    const scheduleRefreshCurrentGitStatus = () => {      if (refreshTimeout !== null) {        window.clearTimeout(refreshTimeout);      }      refreshTimeout = window.setTimeout(() => {        refreshTimeout = null;        void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch(          () => undefined,        );      }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS);    };    const handleVisibilityChange = () => {      if (document.visibilityState === "visible") {        scheduleRefreshCurrentGitStatus();      }    };     window.addEventListener("focus", scheduleRefreshCurrentGitStatus);    document.addEventListener("visibilitychange", handleVisibilityChange);     return () => {      if (refreshTimeout !== null) {        window.clearTimeout(refreshTimeout);      }      window.removeEventListener("focus", scheduleRefreshCurrentGitStatus);      document.removeEventListener("visibilitychange", handleVisibilityChange);    };  }, [activeEnvironmentId, gitCwd]);   const openExistingPr = useCallback(async () => {    const api = readLocalApi();    if (!api) {      toastManager.add({        type: "error",        title: "Link opening is unavailable.",        data: threadToastData,      });      return;    }    const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null;    if (!prUrl) {      toastManager.add({        type: "error",        title: "No open PR found.",        data: threadToastData,      });      return;    }    void api.shell.openExternal(prUrl).catch((err: unknown) => {      toastManager.add(        stackedThreadToast({          type: "error",          title: "Unable to open PR link",          description: err instanceof Error ? err.message : "An error occurred.",          ...(threadToastData !== undefined ? { data: threadToastData } : {}),        }),      );    });  }, [gitStatusForActions, threadToastData]);   runGitActionWithToast = useEffectEvent(    async ({      action,      commitMessage,      onConfirmed,      skipDefaultBranchPrompt = false,      statusOverride,      featureBranch = false,      progressToastId,      filePaths,    }: RunGitActionWithToastInput) => {      const actionStatus = statusOverride ?? gitStatusForActions;      const actionBranch = actionStatus?.branch ?? null;

Lines 1–500 of 1191.