diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.test.tsx b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.test.tsx new file mode 100644 index 000000000..b76f10229 --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.test.tsx @@ -0,0 +1,153 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@features/git-interaction/state/gitInteractionStore", () => ({ + useGitInteractionStore: () => ({ actions: { openBranch: vi.fn() } }), +})); + +vi.mock("@features/git-interaction/utils/getSuggestedBranchName", () => ({ + getSuggestedBranchName: vi.fn(() => null), +})); + +vi.mock("@features/git-interaction/utils/gitCacheKeys", () => ({ + invalidateGitBranchQueries: vi.fn(), +})); + +vi.mock("@renderer/trpc", () => ({ + useTRPC: () => ({ + git: { + getAllBranches: { queryOptions: () => ({ queryKey: ["mock"] }) }, + checkoutBranch: { mutationOptions: () => ({}) }, + }, + }), +})); + +vi.mock("@renderer/utils/toast", () => ({ + toast: { error: vi.fn() }, +})); + +const mutateMock = vi.fn(); +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: [], isLoading: false }), + useMutation: () => ({ mutate: mutateMock }), +})); + +import { BranchSelector } from "./BranchSelector"; + +function renderInTheme(children: React.ReactElement) { + return render({children}); +} + +describe("BranchSelector cloud mode", () => { + it("keeps the trigger enabled while the initial cloud load is in flight", () => { + renderInTheme( + , + ); + + expect(screen.getByRole("combobox", { name: "Branch" })).toBeEnabled(); + }); + + it("surfaces the 'Use input as branch name' action when the typed value is new", async () => { + const user = userEvent.setup(); + renderInTheme( + , + ); + + await user.click(screen.getByRole("combobox", { name: "Branch" })); + + expect( + await screen.findByText('Use "brand-new-branch" as branch name'), + ).toBeInTheDocument(); + }); + + it("hides the typed-name action when the input exactly matches an existing branch", async () => { + const user = userEvent.setup(); + renderInTheme( + , + ); + + await user.click(screen.getByRole("combobox", { name: "Branch" })); + + expect( + screen.queryByText(/Use "main" as branch name/), + ).not.toBeInTheDocument(); + }); + + it("commits the typed value via onBranchSelect when the sentinel action is selected", async () => { + const user = userEvent.setup(); + const onBranchSelect = vi.fn(); + renderInTheme( + , + ); + + await user.click(screen.getByRole("combobox", { name: "Branch" })); + await user.click( + await screen.findByText('Use "brand-new-branch" as branch name'), + ); + + expect(onBranchSelect).toHaveBeenCalledWith("brand-new-branch"); + }); + + it("invokes onCloudBranchCommit when the typed value is committed (so the parent can reset the search)", async () => { + const user = userEvent.setup(); + const onCloudBranchCommit = vi.fn(); + renderInTheme( + , + ); + + await user.click(screen.getByRole("combobox", { name: "Branch" })); + await user.click( + await screen.findByText('Use "brand-new-branch" as branch name'), + ); + + expect(onCloudBranchCommit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx index fb4c1e16b..0f00fce18 100644 --- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx @@ -36,6 +36,13 @@ const COMBOBOX_LIMIT = 50; // plain button the combobox's roving focus skips over. const CREATE_BRANCH_ACTION = "__create_branch__"; +// Sentinel for the "Use '' as branch name" action in cloud mode. +// Positioned first when the list is empty so it's auto-highlighted while the +// (slow) remote search is still running; pushed into the footer once branches +// have loaded so auto-highlight lands on a real branch (typed names that match +// a server-returned prefix would otherwise be shadowed by the literal input). +const USE_INPUT_BRANCH_ACTION = "__use_input_branch__"; + function LoadingRow({ label }: { label: string }) { return (
@@ -136,8 +143,6 @@ export function BranchSelector({ const branches = isCloudMode ? (cloudBranches ?? []) : localBranches; const effectiveLoading = loading || (isCloudMode && cloudBranchesLoading); - const cloudStillLoading = - isCloudMode && cloudBranchesLoading && branches.length === 0 && !open; const branchListLoading = isCloudMode ? !!cloudBranchesLoading : localBranchesLoading; @@ -164,6 +169,40 @@ export function BranchSelector({ }), ); + // In local mode, surface in-progress git operations (rebase/merge/etc.) so the + // user understands why there's no current branch and why we won't let them + // checkout a different one — checkout would fail with a hard-to-read git error. + const localBusy = !isSelectionOnly && busyState?.busy === true; + const busyOperationLabel = + localBusy && busyState?.busy + ? BUSY_OPERATION_LABEL[busyState.operation] + : null; + + const displayText = effectiveLoading + ? "Loading..." + : busyOperationLabel && !displayedBranch + ? busyOperationLabel + : (displayedBranch ?? "No branch"); + + const showSpinner = + effectiveLoading || (isCloudMode && open && cloudBranchesFetchingMore); + + const isDisabled = !!(disabled || !repoPath || localBusy); + const disabledReason = + localBusy && busyOperationLabel + ? `${busyOperationLabel} in progress — finish or abort it to switch branches.` + : null; + const inputValue = isCloudMode ? (cloudSearchQuery ?? "") : searchQuery; + const trimmedInputValue = inputValue.trim(); + const canUseInputBranch = + !isDisabled && + trimmedInputValue.length > 0 && + trimmedInputValue !== displayedBranch; + const showUseInputBranchAction = + isCloudMode && + canUseInputBranch && + !branches.some((branch) => branch === trimmedInputValue); + const handleBranchChange = (value: string | null) => { if (!value) return; if (value === CREATE_BRANCH_ACTION) { @@ -175,12 +214,15 @@ export function BranchSelector({ ); return; } + const branchName = + value === USE_INPUT_BRANCH_ACTION ? trimmedInputValue : value; + if (!branchName) return; if (isSelectionOnly) { - onBranchSelect?.(value); - } else if (value !== currentBranch) { + onBranchSelect?.(branchName); + } else if (branchName !== currentBranch) { checkoutMutation.mutate({ directoryPath: repoPath as string, - branchName: value, + branchName, }); } if (isCloudMode) { @@ -198,49 +240,28 @@ export function BranchSelector({ } }; - // In local mode, surface in-progress git operations (rebase/merge/etc.) so the - // user understands why there's no current branch and why we won't let them - // checkout a different one — checkout would fail with a hard-to-read git error. - const localBusy = !isSelectionOnly && busyState?.busy === true; - const busyOperationLabel = - localBusy && busyState?.busy - ? BUSY_OPERATION_LABEL[busyState.operation] - : null; - - const displayText = effectiveLoading - ? "Loading..." - : busyOperationLabel && !displayedBranch - ? busyOperationLabel - : (displayedBranch ?? "No branch"); - - const showSpinner = - effectiveLoading || (isCloudMode && open && cloudBranchesFetchingMore); - - const isDisabled = !!( - disabled || - !repoPath || - cloudStillLoading || - localBusy - ); - const disabledReason = - localBusy && busyOperationLabel - ? `${busyOperationLabel} in progress — finish or abort it to switch branches.` - : null; - const inputValue = isCloudMode ? (cloudSearchQuery ?? "") : searchQuery; - const trimmedInputValue = inputValue.trim(); - const canUseInputBranch = - !isDisabled && - trimmedInputValue.length > 0 && - trimmedInputValue !== displayedBranch; - const handleUseInputBranch = () => { if (!canUseInputBranch) return; handleBranchChange(trimmedInputValue); }; + const useInputBranchPosition: "leading" | "trailing" | null = + showUseInputBranchAction + ? branches.length === 0 + ? "leading" + : "trailing" + : null; + const comboboxItems = isCloudMode + ? useInputBranchPosition === "leading" + ? [USE_INPUT_BRANCH_ACTION, ...branches] + : useInputBranchPosition === "trailing" + ? [...branches, USE_INPUT_BRANCH_ACTION] + : branches + : [...branches, CREATE_BRANCH_ACTION]; + return ( - {(item: string) => - item === CREATE_BRANCH_ACTION ? ( - + {(item: string) => { + if (item === CREATE_BRANCH_ACTION) { + return ( + + + + Create new branch + + + ); + } + if (item === USE_INPUT_BRANCH_ACTION) { + const useInputItem = ( - Create new branch + Use "{trimmedInputValue}" as branch name - - ) : ( + ); + if (useInputBranchPosition === "trailing") { + return ( + + {useInputItem} + + ); + } + return useInputItem; + } + return ( {item} - ) - } + ); + }} {isCloudMode && cloudBranchesHasMore ? ( diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index eb00b588c..b6e3fb96c 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -728,6 +728,7 @@ export function TaskInput({ cloudSearchQuery={cloudBranchSearchQuery} onCloudPickerClose={handleCloudBranchPickerClose} onCloudSearchChange={handleCloudBranchSearchChange} + onCloudBranchCommit={handleCloudBranchPickerClose} onCloudLoadMore={handleLoadMoreCloudBranches} onRefresh={ workspaceMode === "cloud" diff --git a/apps/code/src/renderer/hooks/useIntegrations.ts b/apps/code/src/renderer/hooks/useIntegrations.ts index 4c07c38b4..c8761ac3c 100644 --- a/apps/code/src/renderer/hooks/useIntegrations.ts +++ b/apps/code/src/renderer/hooks/useIntegrations.ts @@ -5,6 +5,7 @@ import { useIntegrationSelectors, useIntegrationStore, } from "@features/integrations/stores/integrationStore"; +import { useDebounce } from "@hooks/useDebounce"; import type { UserGitHubIntegration } from "@renderer/api/posthogClient"; import { useQueries, useQueryClient } from "@tanstack/react-query"; import { @@ -17,6 +18,12 @@ import { import { useAuthenticatedInfiniteQuery } from "./useAuthenticatedInfiniteQuery"; import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; +// Branch search hits a slow remote endpoint (GitHub via PostHog proxy). Debounce +// keystrokes so we fire at most one request per typing burst. Empty searches +// skip the debounce so closing the picker (which resets search to "") clears +// stale results immediately. +const BRANCH_SEARCH_DEBOUNCE_MS = 300; + const integrationKeys = { all: ["integrations"] as const, list: () => [...integrationKeys.all, "list"] as const, @@ -366,11 +373,15 @@ export function useGithubBranches( search?: string, enabled: boolean = true, ) { - const deferredSearch = useDeferredValue(search?.trim() ?? ""); + const trimmedSearch = search?.trim() ?? ""; + const debouncedSearch = useDebounce( + trimmedSearch, + trimmedSearch ? BRANCH_SEARCH_DEBOUNCE_MS : 0, + ); const queryEnabled = enabled && !!integrationId && !!repo; const query = useAuthenticatedInfiniteQuery( - integrationKeys.branches(integrationId, repo, deferredSearch), + integrationKeys.branches(integrationId, repo, debouncedSearch), async (client, offset) => { if (!integrationId || !repo) { return { branches: [], defaultBranch: null, hasMore: false }; @@ -382,7 +393,7 @@ export function useGithubBranches( repo, offset, pageSize, - deferredSearch, + debouncedSearch, ); }, { @@ -435,11 +446,15 @@ export function useUserGithubBranches( search?: string, enabled: boolean = true, ) { - const deferredSearch = useDeferredValue(search?.trim() ?? ""); + const trimmedSearch = search?.trim() ?? ""; + const debouncedSearch = useDebounce( + trimmedSearch, + trimmedSearch ? BRANCH_SEARCH_DEBOUNCE_MS : 0, + ); const queryEnabled = enabled && !!installationId && !!repo; const query = useAuthenticatedInfiniteQuery( - userGithubIntegrationKeys.branches(installationId, repo, deferredSearch), + userGithubIntegrationKeys.branches(installationId, repo, debouncedSearch), async (client, offset) => { if (!installationId || !repo) { return { branches: [], defaultBranch: null, hasMore: false }; @@ -451,7 +466,7 @@ export function useUserGithubBranches( repo, offset, pageSize, - deferredSearch, + debouncedSearch, ); }, {