Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(<Theme>{children}</Theme>);
}

describe("BranchSelector cloud mode", () => {
it("keeps the trigger enabled while the initial cloud load is in flight", () => {
renderInTheme(
<BranchSelector
repoPath="owner/repo"
currentBranch={null}
workspaceMode="cloud"
cloudBranches={[]}
cloudBranchesLoading={true}
cloudSearchQuery=""
onBranchSelect={vi.fn()}
onCloudSearchChange={vi.fn()}
/>,
);

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(
<BranchSelector
repoPath="owner/repo"
currentBranch={null}
workspaceMode="cloud"
cloudBranches={["main", "feature-a"]}
cloudBranchesLoading={false}
cloudSearchQuery="brand-new-branch"
onBranchSelect={vi.fn()}
onCloudSearchChange={vi.fn()}
/>,
);

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(
<BranchSelector
repoPath="owner/repo"
currentBranch={null}
workspaceMode="cloud"
cloudBranches={["main", "feature-a"]}
cloudBranchesLoading={false}
cloudSearchQuery="main"
onBranchSelect={vi.fn()}
onCloudSearchChange={vi.fn()}
/>,
);

await user.click(screen.getByRole("combobox", { name: "Branch" }));

expect(
screen.queryByText(/Use "main" as branch name/),
).not.toBeInTheDocument();
});
Comment thread
charlesvien marked this conversation as resolved.

it("commits the typed value via onBranchSelect when the sentinel action is selected", async () => {
const user = userEvent.setup();
const onBranchSelect = vi.fn();
renderInTheme(
<BranchSelector
repoPath="owner/repo"
currentBranch={null}
workspaceMode="cloud"
cloudBranches={[]}
cloudBranchesLoading={true}
cloudSearchQuery="brand-new-branch"
onBranchSelect={onBranchSelect}
onCloudSearchChange={vi.fn()}
/>,
);

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(
<BranchSelector
repoPath="owner/repo"
currentBranch={null}
workspaceMode="cloud"
cloudBranches={[]}
cloudBranchesLoading={true}
cloudSearchQuery="brand-new-branch"
onBranchSelect={vi.fn()}
onCloudSearchChange={vi.fn()}
onCloudBranchCommit={onCloudBranchCommit}
/>,
);

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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<input>' 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 (
<div className="flex items-center gap-1 px-2 py-1.5 text-muted-foreground text-xs">
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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 (
<Combobox
items={isCloudMode ? branches : [...branches, CREATE_BRANCH_ACTION]}
items={comboboxItems}
limit={COMBOBOX_LIMIT}
autoHighlight
value={displayedBranch}
Expand Down Expand Up @@ -378,19 +399,43 @@ export function BranchSelector({
)}

<ComboboxList className="max-h-[min(14rem,calc(var(--available-height,14rem)-5rem))]">
{(item: string) =>
item === CREATE_BRANCH_ACTION ? (
<ComboboxListFooter key="footer">
{(item: string) => {
if (item === CREATE_BRANCH_ACTION) {
return (
<ComboboxListFooter key="footer">
<ComboboxItem
value={CREATE_BRANCH_ACTION}
title="Create new branch"
className="text-accent-foreground"
>
<Plus size={11} weight="bold" />
Create new branch
</ComboboxItem>
</ComboboxListFooter>
);
}
if (item === USE_INPUT_BRANCH_ACTION) {
const useInputItem = (
<ComboboxItem
value={CREATE_BRANCH_ACTION}
title="Create new branch"
key={USE_INPUT_BRANCH_ACTION}
value={USE_INPUT_BRANCH_ACTION}
title={`Use "${trimmedInputValue}" as branch name`}
className="text-accent-foreground"
>
<Plus size={11} weight="bold" />
Create new branch
Use "{trimmedInputValue}" as branch name
</ComboboxItem>
</ComboboxListFooter>
) : (
);
if (useInputBranchPosition === "trailing") {
return (
<ComboboxListFooter key="use-input-footer">
{useInputItem}
</ComboboxListFooter>
);
}
return useInputItem;
}
return (
<ComboboxItem
key={item}
value={item}
Expand All @@ -399,8 +444,8 @@ export function BranchSelector({
>
{item}
</ComboboxItem>
)
}
);
}}
</ComboboxList>

{isCloudMode && cloudBranchesHasMore ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@ export function TaskInput({
cloudSearchQuery={cloudBranchSearchQuery}
onCloudPickerClose={handleCloudBranchPickerClose}
onCloudSearchChange={handleCloudBranchSearchChange}
onCloudBranchCommit={handleCloudBranchPickerClose}
onCloudLoadMore={handleLoadMoreCloudBranches}
onRefresh={
workspaceMode === "cloud"
Expand Down
Loading
Loading