Skip to content

Commit f225109

Browse files
committed
fix: infer existing branch when typed but not selected
- When the user types an exact branch name and leaves the field, auto-select the matching existing branch to keep workspace creation semantics correct - Prefer origin when the same remote branch name exists on multiple remotes - Add unit tests for selection inference Change-Id: I6b3652949bc54534096afae6616965cacc7788b3 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent ee4665d commit f225109

File tree

3 files changed

+141
-20
lines changed

3 files changed

+141
-20
lines changed

src/browser/components/BranchNameInput.tsx

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import React, { useCallback, useMemo, useRef, useState } from "react";
1+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import { Check, ChevronRight, Globe, Loader2, Plus, Wand2 } from "lucide-react";
33
import { cn } from "@/common/lib/utils";
44
import { Popover, PopoverContent, PopoverAnchor } from "./ui/popover";
55
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
66
import type { ExistingBranchSelection } from "@/common/types/branchSelection";
7+
import { inferExactExistingBranchSelection } from "./branchNameInputSelection";
78
import type { BranchListResult } from "@/common/orpc/types";
89

910
interface RemoteGroup {
@@ -70,6 +71,12 @@ export function BranchNameInput(props: BranchNameInputProps) {
7071
} = props;
7172

7273
const inputRef = useRef<HTMLInputElement>(null);
74+
const selectedExistingBranchRef = useRef<ExistingBranchSelection | null>(selectedExistingBranch);
75+
76+
useEffect(() => {
77+
selectedExistingBranchRef.current = selectedExistingBranch;
78+
}, [selectedExistingBranch]);
79+
7380
const [isOpen, setIsOpen] = useState(false);
7481
const [expandedRemotes, setExpandedRemotes] = useState<Set<string>>(new Set());
7582

@@ -106,15 +113,17 @@ export function BranchNameInput(props: BranchNameInputProps) {
106113
const hasAnyBranches =
107114
localBranches.length > 0 || remoteGroups.some((g) => g.branches.length > 0);
108115

109-
// Check if input exactly matches an existing branch
110-
const exactLocalMatch = localBranches.find((b) => b.toLowerCase() === searchLower);
111-
const exactRemoteMatch = remoteGroups.find((g) =>
112-
g.branches.some((b) => b.toLowerCase() === searchLower)
116+
const exactExistingBranchSelection = useMemo(
117+
() =>
118+
inferExactExistingBranchSelection({
119+
value,
120+
localBranches,
121+
remoteGroups,
122+
}),
123+
[value, localBranches, remoteGroups]
113124
);
114-
const hasExactMatch = exactLocalMatch ?? exactRemoteMatch;
115125

116-
// Show "Create new branch" option when there's input that doesn't exactly match
117-
const showCreateOption = value.length > 0 && !hasExactMatch;
126+
const showCreateOption = value.length > 0 && exactExistingBranchSelection === null;
118127

119128
// Handle input focus - show dropdown and disable auto-generate
120129
const handleFocus = useCallback(() => {
@@ -170,13 +179,33 @@ export function BranchNameInput(props: BranchNameInputProps) {
170179
inputRef.current?.blur();
171180
}, [onSelectExistingBranch]);
172181

173-
// Handle input blur - close dropdown
182+
// Handle input blur - close dropdown and opportunistically auto-select exact matches.
183+
//
184+
// Important: We only auto-select when the user *typed* an exact match but didn't click
185+
// a dropdown item. If a dropdown item click already selected a branch, don't override it.
174186
const handleBlur = useCallback(() => {
175187
// Small delay to allow click events on dropdown items to fire first
176188
setTimeout(() => {
177189
setIsOpen(false);
190+
191+
if (disabled || !branchesLoaded) return;
192+
193+
// If the user clicked a dropdown item, selection was already set.
194+
if (selectedExistingBranchRef.current) return;
195+
196+
if (value.length === 0) return;
197+
198+
if (exactExistingBranchSelection) {
199+
onSelectExistingBranch(exactExistingBranchSelection);
200+
}
178201
}, 150);
179-
}, []);
202+
}, [
203+
branchesLoaded,
204+
disabled,
205+
exactExistingBranchSelection,
206+
onSelectExistingBranch,
207+
value.length,
208+
]);
180209

181210
// Handle keyboard navigation
182211
const handleKeyDown = useCallback(
@@ -186,23 +215,21 @@ export function BranchNameInput(props: BranchNameInputProps) {
186215
inputRef.current?.blur();
187216
} else if (e.key === "Enter") {
188217
// If exact match exists, select it
189-
if (exactLocalMatch) {
190-
handleSelectLocalBranch(exactLocalMatch);
191-
} else if (exactRemoteMatch) {
192-
const branch = exactRemoteMatch.branches.find((b) => b.toLowerCase() === searchLower);
193-
if (branch) {
194-
handleSelectRemoteBranch(exactRemoteMatch.remote, branch);
195-
}
218+
if (exactExistingBranchSelection?.kind === "local") {
219+
handleSelectLocalBranch(exactExistingBranchSelection.branch);
220+
} else if (exactExistingBranchSelection?.kind === "remote") {
221+
handleSelectRemoteBranch(
222+
exactExistingBranchSelection.remote,
223+
exactExistingBranchSelection.branch
224+
);
196225
} else if (value.length > 0) {
197226
// No match - use as new branch name
198227
handleSelectCreateNew();
199228
}
200229
}
201230
},
202231
[
203-
exactLocalMatch,
204-
exactRemoteMatch,
205-
searchLower,
232+
exactExistingBranchSelection,
206233
value.length,
207234
handleSelectLocalBranch,
208235
handleSelectRemoteBranch,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { inferExactExistingBranchSelection } from "./branchNameInputSelection";
4+
5+
describe("inferExactExistingBranchSelection", () => {
6+
test("returns local selection on exact local match", () => {
7+
const selection = inferExactExistingBranchSelection({
8+
value: "feature/foo",
9+
localBranches: ["main", "feature/foo"],
10+
remoteGroups: [{ remote: "origin", branches: ["feature/foo"] }],
11+
});
12+
13+
expect(selection).toEqual({ kind: "local", branch: "feature/foo" });
14+
});
15+
16+
test("returns remote selection on exact remote match", () => {
17+
const selection = inferExactExistingBranchSelection({
18+
value: "feature/foo",
19+
localBranches: ["main"],
20+
remoteGroups: [{ remote: "origin", branches: ["feature/foo"] }],
21+
});
22+
23+
expect(selection).toEqual({ kind: "remote", remote: "origin", branch: "feature/foo" });
24+
});
25+
26+
test("prefers origin when multiple remotes contain the same branch", () => {
27+
const selection = inferExactExistingBranchSelection({
28+
value: "feature/foo",
29+
localBranches: [],
30+
remoteGroups: [
31+
{ remote: "upstream", branches: ["feature/foo"] },
32+
{ remote: "origin", branches: ["feature/foo"] },
33+
],
34+
});
35+
36+
expect(selection).toEqual({ kind: "remote", remote: "origin", branch: "feature/foo" });
37+
});
38+
39+
test("returns null when there is no exact match", () => {
40+
const selection = inferExactExistingBranchSelection({
41+
value: "feature/foo",
42+
localBranches: ["main"],
43+
remoteGroups: [{ remote: "origin", branches: ["feature/bar"] }],
44+
});
45+
46+
expect(selection).toBeNull();
47+
});
48+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { ExistingBranchSelection } from "@/common/types/branchSelection";
2+
3+
export interface RemoteBranchGroupLike {
4+
remote: string;
5+
branches: string[];
6+
}
7+
8+
/**
9+
* Infer whether the current input value exactly matches an existing branch.
10+
*
11+
* This is used to keep workspace creation semantics correct:
12+
* - If the name matches an existing local branch, treat it as existing.
13+
* - If the name matches an existing remote-only branch, treat it as existing and prefer `origin`
14+
* when the same branch name exists on multiple remotes.
15+
*/
16+
export function inferExactExistingBranchSelection(params: {
17+
value: string;
18+
localBranches: string[];
19+
remoteGroups: RemoteBranchGroupLike[];
20+
}): ExistingBranchSelection | null {
21+
if (params.value.length === 0) return null;
22+
23+
const searchLower = params.value.toLowerCase();
24+
25+
const exactLocalMatch = params.localBranches.find((b) => b.toLowerCase() === searchLower);
26+
if (exactLocalMatch) {
27+
return { kind: "local", branch: exactLocalMatch };
28+
}
29+
30+
const remoteMatches: Array<{ remote: string; branch: string }> = [];
31+
for (const group of params.remoteGroups) {
32+
const branch = group.branches.find((b) => b.toLowerCase() === searchLower);
33+
if (branch) {
34+
remoteMatches.push({ remote: group.remote, branch });
35+
}
36+
}
37+
38+
if (remoteMatches.length === 0) return null;
39+
40+
const chosen =
41+
remoteMatches.length === 1
42+
? remoteMatches[0]
43+
: (remoteMatches.find((m) => m.remote === "origin") ?? remoteMatches[0]);
44+
45+
return { kind: "remote", remote: chosen.remote, branch: chosen.branch };
46+
}

0 commit comments

Comments
 (0)