633 lines · 21.1 KB · TypeScript
RawDownload
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts";import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";import { useVirtualizer } from "@tanstack/react-virtual";import { ChevronDownIcon } from "lucide-react";import {  type CSSProperties,  useCallback,  useDeferredValue,  useEffect,  useMemo,  useOptimistic,  useRef,  useState,  useTransition,} from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore";import { readEnvironmentApi } from "../environmentApi";import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery";import { useGitStatus } from "../lib/gitStatusState";import { newCommandId } from "../lib/utils";import { parsePullRequestReference } from "../pullRequestReference";import { useStore } from "../store";import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";import {  deriveLocalBranchNameFromRemoteRef,  resolveBranchSelectionTarget,  resolveBranchToolbarValue,  resolveDraftEnvModeAfterBranchChange,  resolveEffectiveEnvMode,  shouldIncludeBranchPickerItem,} from "./BranchToolbar.logic";import { Button } from "./ui/button";import {  Combobox,  ComboboxEmpty,  ComboboxInput,  ComboboxItem,  ComboboxList,  ComboboxPopup,  ComboboxStatus,  ComboboxTrigger,} from "./ui/combobox";import { toastManager } from "./ui/toast"; interface BranchToolbarBranchSelectorProps {  environmentId: EnvironmentId;  threadId: ThreadId;  draftId?: DraftId;  envLocked: boolean;  onCheckoutPullRequestRequest?: (reference: string) => void;  onComposerFocusRequest?: () => void;} function toBranchActionErrorMessage(error: unknown): string {  return error instanceof Error ? error.message : "An error occurred.";} function getBranchTriggerLabel(input: {  activeWorktreePath: string | null;  effectiveEnvMode: "local" | "worktree";  resolvedActiveBranch: string | null;}): string {  const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input;  if (!resolvedActiveBranch) {    return "Select branch";  }  if (effectiveEnvMode === "worktree" && !activeWorktreePath) {    return `From ${resolvedActiveBranch}`;  }  return resolvedActiveBranch;} export function BranchToolbarBranchSelector({  environmentId,  threadId,  draftId,  envLocked,  onCheckoutPullRequestRequest,  onComposerFocusRequest,}: BranchToolbarBranchSelectorProps) {  // ---------------------------------------------------------------------------  // Thread / project state (pushed down from parent to colocate with mutation)  // ---------------------------------------------------------------------------  const threadRef = useMemo(    () => scopeThreadRef(environmentId, threadId),    [environmentId, threadId],  );  const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]);  const serverThread = useStore(serverThreadSelector);  const serverSession = serverThread?.session ?? null;  const setThreadBranchAction = useStore((store) => store.setThreadBranch);  const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));  const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);   const activeProjectRef = serverThread    ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)    : draftThread      ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)      : null;  const activeProjectSelector = useMemo(    () => createProjectSelectorByRef(activeProjectRef),    [activeProjectRef],  );  const activeProject = useStore(activeProjectSelector);   const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);  const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;  const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;  const activeProjectCwd = activeProject?.cwd ?? null;  const branchCwd = activeWorktreePath ?? activeProjectCwd;  const hasServerThread = serverThread !== undefined;  const effectiveEnvMode = resolveEffectiveEnvMode({    activeWorktreePath,    hasServerThread,    draftThreadEnvMode: draftThread?.envMode,  });   // ---------------------------------------------------------------------------  // Thread branch mutation (colocated — only this component calls it)  // ---------------------------------------------------------------------------  const setThreadBranch = useCallback(    (branch: string | null, worktreePath: string | null) => {      if (!activeThreadId || !activeProject) return;      const api = readEnvironmentApi(environmentId);      if (serverSession && worktreePath !== activeWorktreePath && api) {        void api.orchestration          .dispatchCommand({            type: "thread.session.stop",            commandId: newCommandId(),            threadId: activeThreadId,            createdAt: new Date().toISOString(),          })          .catch(() => undefined);      }      if (api && hasServerThread) {        void api.orchestration.dispatchCommand({          type: "thread.meta.update",          commandId: newCommandId(),          threadId: activeThreadId,          branch,          worktreePath,        });      }      if (hasServerThread) {        setThreadBranchAction(threadRef, branch, worktreePath);        return;      }      const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({        nextWorktreePath: worktreePath,        currentWorktreePath: activeWorktreePath,        effectiveEnvMode,      });      setDraftThreadContext(draftId ?? threadRef, {        branch,        worktreePath,        envMode: nextDraftEnvMode,        projectRef: scopeProjectRef(environmentId, activeProject.id),      });    },    [      activeThreadId,      activeProject,      serverSession,      activeWorktreePath,      hasServerThread,      setThreadBranchAction,      setDraftThreadContext,      draftId,      threadRef,      environmentId,      effectiveEnvMode,    ],  );   // ---------------------------------------------------------------------------  // Git branch queries  // ---------------------------------------------------------------------------  const queryClient = useQueryClient();  const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);  const [branchQuery, setBranchQuery] = useState("");  const deferredBranchQuery = useDeferredValue(branchQuery);   const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });  const trimmedBranchQuery = branchQuery.trim();  const deferredTrimmedBranchQuery = deferredBranchQuery.trim();   useEffect(() => {    if (!branchCwd) return;    void queryClient.prefetchInfiniteQuery(      gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }),    );  }, [branchCwd, environmentId, queryClient]);   const {    data: branchesSearchData,    fetchNextPage,    hasNextPage,    isFetchingNextPage,    isPending: isBranchesSearchPending,  } = useInfiniteQuery(    gitBranchSearchInfiniteQueryOptions({      environmentId,      cwd: branchCwd,      query: deferredTrimmedBranchQuery,    }),  );  const branches = useMemo(    () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [],    [branchesSearchData?.pages],  );  const currentGitBranch =    branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null;  const canonicalActiveBranch = resolveBranchToolbarValue({    envMode: effectiveEnvMode,    activeWorktreePath,    activeThreadBranch,    currentGitBranch,  });  const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]);  const branchByName = useMemo(    () => new Map(branches.map((branch) => [branch.name, branch] as const)),    [branches],  );  const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase();  const prReference = parsePullRequestReference(trimmedBranchQuery);  const isSelectingWorktreeBase =    effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath;  const checkoutPullRequestItemValue =    prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null;  const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0;  const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);  const createBranchItemValue = canCreateBranch    ? `__create_new_branch__:${trimmedBranchQuery}`    : null;  const branchPickerItems = useMemo(() => {    const items = [...branchNames];    if (createBranchItemValue && !hasExactBranchMatch) {      items.push(createBranchItemValue);    }    if (checkoutPullRequestItemValue) {      items.unshift(checkoutPullRequestItemValue);    }    return items;  }, [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch]);  const filteredBranchPickerItems = useMemo(    () =>      normalizedDeferredBranchQuery.length === 0        ? branchPickerItems        : branchPickerItems.filter((itemValue) =>            shouldIncludeBranchPickerItem({              itemValue,              normalizedQuery: normalizedDeferredBranchQuery,              createBranchItemValue,              checkoutPullRequestItemValue,            }),          ),    [      branchPickerItems,      checkoutPullRequestItemValue,      createBranchItemValue,      normalizedDeferredBranchQuery,    ],  );  const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(    canonicalActiveBranch,    (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,  );  const [isBranchActionPending, startBranchActionTransition] = useTransition();  const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;  const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0;  const branchStatusText = isBranchesSearchPending    ? "Loading branches..."    : isFetchingNextPage      ? "Loading more branches..."      : hasNextPage        ? `Showing ${branches.length} of ${totalBranchCount} branches`        : null;   // ---------------------------------------------------------------------------  // Branch actions  // ---------------------------------------------------------------------------  const runBranchAction = (action: () => Promise<void>) => {    startBranchActionTransition(async () => {      await action().catch(() => undefined);      await queryClient        .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) })        .catch(() => undefined);    });  };   const selectBranch = (branch: GitBranch) => {    const api = readEnvironmentApi(environmentId);    if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return;     if (isSelectingWorktreeBase) {      setThreadBranch(branch.name, null);      setIsBranchMenuOpen(false);      onComposerFocusRequest?.();      return;    }     const selectionTarget = resolveBranchSelectionTarget({      activeProjectCwd,      activeWorktreePath,      branch,    });     if (selectionTarget.reuseExistingWorktree) {      setThreadBranch(branch.name, selectionTarget.nextWorktreePath);      setIsBranchMenuOpen(false);      onComposerFocusRequest?.();      return;    }     const selectedBranchName = branch.isRemote      ? deriveLocalBranchNameFromRemoteRef(branch.name)      : branch.name;     setIsBranchMenuOpen(false);    onComposerFocusRequest?.();     runBranchAction(async () => {      const previousBranch = resolvedActiveBranch;      setOptimisticBranch(selectedBranchName);      try {        const checkoutResult = await api.git.checkout({          cwd: selectionTarget.checkoutCwd,          branch: branch.name,        });        const nextBranchName = branch.isRemote          ? (checkoutResult.branch ?? selectedBranchName)          : selectedBranchName;        setOptimisticBranch(nextBranchName);        setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);      } catch (error) {        setOptimisticBranch(previousBranch);        toastManager.add({          type: "error",          title: "Failed to checkout branch.",          description: toBranchActionErrorMessage(error),        });      }    });  };   const createBranch = (rawName: string) => {    const name = rawName.trim();    const api = readEnvironmentApi(environmentId);    if (!api || !branchCwd || !name || isBranchActionPending) return;     setIsBranchMenuOpen(false);    onComposerFocusRequest?.();     runBranchAction(async () => {      const previousBranch = resolvedActiveBranch;      setOptimisticBranch(name);      try {        const createBranchResult = await api.git.createBranch({          cwd: branchCwd,          branch: name,          checkout: true,        });        setOptimisticBranch(createBranchResult.branch);        setThreadBranch(createBranchResult.branch, activeWorktreePath);      } catch (error) {        setOptimisticBranch(previousBranch);        toastManager.add({          type: "error",          title: "Failed to create and checkout branch.",          description: toBranchActionErrorMessage(error),        });      }    });  };   useEffect(() => {    if (      effectiveEnvMode !== "worktree" ||      activeWorktreePath ||      activeThreadBranch ||      !currentGitBranch    ) {      return;    }    setThreadBranch(currentGitBranch, null);  }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]);   // ---------------------------------------------------------------------------  // Combobox / virtualizer plumbing  // ---------------------------------------------------------------------------  const handleOpenChange = useCallback(    (open: boolean) => {      setIsBranchMenuOpen(open);      if (!open) {        setBranchQuery("");        return;      }      void queryClient.invalidateQueries({        queryKey: gitQueryKeys.branches(environmentId, branchCwd),      });    },    [branchCwd, environmentId, queryClient],  );   const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);  const maybeFetchNextBranchPage = useCallback(() => {    if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) {      return;    }     const scrollElement = branchListScrollElementRef.current;    if (!scrollElement) {      return;    }     const distanceFromBottom =      scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight;    if (distanceFromBottom > 96) {      return;    }     void fetchNextPage().catch(() => undefined);  }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);  const branchListVirtualizer = useVirtualizer({    count: filteredBranchPickerItems.length,    estimateSize: (index) =>      filteredBranchPickerItems[index] === checkoutPullRequestItemValue ? 44 : 28,    getScrollElement: () => branchListScrollElementRef.current,    overscan: 12,    enabled: isBranchMenuOpen && shouldVirtualizeBranchList,    initialRect: {      height: 224,      width: 0,    },  });  const virtualBranchRows = branchListVirtualizer.getVirtualItems();  const setBranchListRef = useCallback(    (element: HTMLDivElement | null) => {      branchListScrollElementRef.current =        (element?.parentElement as HTMLDivElement | null) ?? null;      if (element) {        branchListVirtualizer.measure();      }    },    [branchListVirtualizer],  );   useEffect(() => {    if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return;    queueMicrotask(() => {      branchListVirtualizer.measure();    });  }, [    branchListVirtualizer,    filteredBranchPickerItems.length,    isBranchMenuOpen,    shouldVirtualizeBranchList,  ]);   useEffect(() => {    if (!isBranchMenuOpen) {      return;    }     branchListScrollElementRef.current?.scrollTo({ top: 0 });  }, [deferredTrimmedBranchQuery, isBranchMenuOpen]);   useEffect(() => {    const scrollElement = branchListScrollElementRef.current;    if (!scrollElement || !isBranchMenuOpen) {      return;    }     const handleScroll = () => {      maybeFetchNextBranchPage();    };     scrollElement.addEventListener("scroll", handleScroll, { passive: true });    handleScroll();    return () => {      scrollElement.removeEventListener("scroll", handleScroll);    };  }, [isBranchMenuOpen, maybeFetchNextBranchPage]);   useEffect(() => {    maybeFetchNextBranchPage();  }, [branches.length, maybeFetchNextBranchPage]);   const triggerLabel = getBranchTriggerLabel({    activeWorktreePath,    effectiveEnvMode,    resolvedActiveBranch,  });   function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) {    if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {      return (        <ComboboxItem

Lines 1–500 of 633.