Skip to content

Commit 2334b3d

Browse files
committed
feat: reuse branch picker for workspace creation
Change-Id: Icd3157070795b352712304a8b56417ba74f28364 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 9d299ba commit 2334b3d

24 files changed

+689
-260
lines changed

src/browser/App.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { getThinkingLevelByModelKey, getModelKey } from "@/common/constants/stor
3838
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
3939
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
4040
import type { BranchListResult } from "@/common/orpc/types";
41+
import type { ExistingBranchSelection } from "@/common/types/branchSelection";
4142
import { useTelemetry } from "./hooks/useTelemetry";
4243
import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
4344
import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation";
@@ -325,16 +326,16 @@ function AppInner() {
325326
);
326327

327328
const openBranchAsWorkspaceFromPalette = useCallback(
328-
(projectPath: string, branchName: string) => {
329-
startWorkspaceCreation(projectPath, { projectPath, existingBranch: branchName });
329+
(projectPath: string, selection: ExistingBranchSelection) => {
330+
startWorkspaceCreation(projectPath, { projectPath, existingBranch: selection });
330331
},
331332
[startWorkspaceCreation]
332333
);
333334

334335
const getBranchesForProject = useCallback(
335336
async (projectPath: string): Promise<BranchListResult> => {
336337
if (!api) {
337-
return { branches: [], remoteBranches: [], recommendedTrunk: null };
338+
return { branches: [], remoteBranches: [], remoteBranchGroups: [], recommendedTrunk: null };
338339
}
339340
const branchResult = await api.projects.listBranches({ projectPath });
340341
const sanitizedBranches = branchResult.branches.filter(
@@ -345,6 +346,22 @@ function AppInner() {
345346
(branch): branch is string => typeof branch === "string"
346347
);
347348

349+
const sanitizedRemoteBranchGroups = Array.isArray(branchResult.remoteBranchGroups)
350+
? branchResult.remoteBranchGroups
351+
.filter(
352+
(group): group is { remote: string; branches: string[]; truncated: boolean } =>
353+
typeof group?.remote === "string" &&
354+
Array.isArray(group.branches) &&
355+
typeof group.truncated === "boolean"
356+
)
357+
.map((group) => ({
358+
remote: group.remote,
359+
branches: group.branches.filter((b): b is string => typeof b === "string"),
360+
truncated: group.truncated,
361+
}))
362+
.filter((group) => group.remote.length > 0 && group.branches.length > 0)
363+
: [];
364+
348365
const recommended =
349366
branchResult.recommendedTrunk && sanitizedBranches.includes(branchResult.recommendedTrunk)
350367
? branchResult.recommendedTrunk
@@ -353,6 +370,7 @@ function AppInner() {
353370
return {
354371
branches: sanitizedBranches,
355372
remoteBranches: sanitizedRemoteBranches,
373+
remoteBranchGroups: sanitizedRemoteBranchGroups,
356374
recommendedTrunk: recommended,
357375
};
358376
},
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import React, { useEffect, useMemo, useRef, useState } from "react";
2+
import { Check, ChevronRight, Globe, Loader2 } from "lucide-react";
3+
import { cn } from "@/common/lib/utils";
4+
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
5+
6+
export type BranchPickerSelection =
7+
| { kind: "local"; branch: string }
8+
| { kind: "remote"; remote: string; branch: string };
9+
10+
export interface BranchPickerRemoteGroup {
11+
remote: string;
12+
branches: string[];
13+
isLoading?: boolean;
14+
fetched?: boolean;
15+
truncated?: boolean;
16+
}
17+
18+
interface BranchPickerPopoverProps {
19+
trigger: React.ReactNode;
20+
disabled?: boolean;
21+
22+
isLoading?: boolean;
23+
localBranches: string[];
24+
localBranchesTruncated?: boolean;
25+
26+
remotes: BranchPickerRemoteGroup[];
27+
28+
selection: BranchPickerSelection | null;
29+
30+
onOpen?: () => void | Promise<void>;
31+
onClose?: () => void;
32+
onSelectLocalBranch: (branch: string) => void | Promise<void>;
33+
onSelectRemoteBranch: (remote: string, branch: string) => void | Promise<void>;
34+
onExpandRemote?: (remote: string) => void | Promise<void>;
35+
}
36+
37+
export function BranchPickerPopover(props: BranchPickerPopoverProps) {
38+
const { onClose, onExpandRemote, onOpen, onSelectLocalBranch, onSelectRemoteBranch } = props;
39+
40+
const inputRef = useRef<HTMLInputElement>(null);
41+
const [isOpen, setIsOpen] = useState(false);
42+
const [search, setSearch] = useState("");
43+
const [expandedRemotes, setExpandedRemotes] = useState<Set<string>>(new Set());
44+
45+
useEffect(() => {
46+
if (!isOpen) return;
47+
void onOpen?.();
48+
}, [isOpen, onOpen]);
49+
50+
useEffect(() => {
51+
if (!isOpen) {
52+
onClose?.();
53+
setSearch("");
54+
setExpandedRemotes(new Set());
55+
return;
56+
}
57+
58+
const timer = setTimeout(() => inputRef.current?.focus(), 50);
59+
return () => clearTimeout(timer);
60+
}, [isOpen, onClose]);
61+
62+
const searchLower = search.toLowerCase();
63+
64+
const filteredLocalBranches = useMemo(
65+
() => props.localBranches.filter((b) => b.toLowerCase().includes(searchLower)),
66+
[props.localBranches, searchLower]
67+
);
68+
69+
const remoteGroups = useMemo(() => {
70+
return props.remotes.map((remote) => ({
71+
remote: remote.remote,
72+
branches: remote.branches,
73+
isLoading: remote.isLoading ?? false,
74+
fetched: remote.fetched ?? true,
75+
truncated: remote.truncated ?? false,
76+
}));
77+
}, [props.remotes]);
78+
79+
const getFilteredRemoteBranches = (remote: string) => {
80+
const group = remoteGroups.find((g) => g.remote === remote);
81+
if (!group) return [];
82+
return group.branches.filter((b) => b.toLowerCase().includes(searchLower));
83+
};
84+
85+
const hasMatchingRemoteBranches = remoteGroups.some((group) => {
86+
if (!group.fetched) return true;
87+
return getFilteredRemoteBranches(group.remote).length > 0;
88+
});
89+
90+
const toggleRemote = (remote: string) => {
91+
setExpandedRemotes((prev) => {
92+
const next = new Set(prev);
93+
if (next.has(remote)) {
94+
next.delete(remote);
95+
} else {
96+
next.add(remote);
97+
void onExpandRemote?.(remote);
98+
}
99+
return next;
100+
});
101+
};
102+
103+
const handleSelectLocalBranch = (branch: string) => {
104+
setIsOpen(false);
105+
void onSelectLocalBranch(branch);
106+
};
107+
108+
const handleSelectRemoteBranch = (remote: string, branch: string) => {
109+
setIsOpen(false);
110+
void onSelectRemoteBranch(remote, branch);
111+
};
112+
113+
const isRemoteBranchSelected = (remote: string, branch: string) => {
114+
const selection = props.selection;
115+
if (!selection) return false;
116+
117+
if (selection.kind === "remote") {
118+
return selection.remote === remote && selection.branch === branch;
119+
}
120+
121+
// Useful for workspaces where selection is the local branch, but we still want the
122+
// remote list to indicate which remote branch corresponds to the current branch.
123+
return selection.branch === branch;
124+
};
125+
126+
return (
127+
<Popover open={isOpen} onOpenChange={setIsOpen}>
128+
<PopoverTrigger asChild>{props.trigger}</PopoverTrigger>
129+
<PopoverContent align="start" className="w-[220px] p-0">
130+
{/* Search input */}
131+
<div className="border-border border-b px-2 py-1.5">
132+
<input
133+
ref={inputRef}
134+
type="text"
135+
value={search}
136+
onChange={(e) => setSearch(e.target.value)}
137+
placeholder="Search branches..."
138+
className="text-foreground placeholder:text-muted w-full bg-transparent font-mono text-[11px] outline-none"
139+
/>
140+
</div>
141+
142+
<div className="max-h-[280px] overflow-y-auto p-1">
143+
{/* Remotes as expandable groups */}
144+
{remoteGroups.length > 0 && hasMatchingRemoteBranches && (
145+
<>
146+
{remoteGroups.map((group) => {
147+
const isExpanded = expandedRemotes.has(group.remote);
148+
const filteredRemoteBranches = getFilteredRemoteBranches(group.remote);
149+
150+
// Hide remote if fetched and no matching branches
151+
if (group.fetched && filteredRemoteBranches.length === 0 && search) {
152+
return null;
153+
}
154+
155+
return (
156+
<div key={group.remote}>
157+
<button
158+
type="button"
159+
onClick={() => toggleRemote(group.remote)}
160+
className="hover:bg-hover flex w-full items-center gap-1.5 rounded-sm px-2 py-1 font-mono text-[11px]"
161+
>
162+
<ChevronRight
163+
className={cn(
164+
"text-muted h-3 w-3 shrink-0 transition-transform",
165+
isExpanded && "rotate-90"
166+
)}
167+
/>
168+
<Globe className="text-muted h-3 w-3 shrink-0" />
169+
<span>{group.remote}</span>
170+
</button>
171+
172+
{isExpanded && (
173+
<div className="ml-3">
174+
{group.isLoading ? (
175+
<div className="text-muted flex items-center justify-center py-2">
176+
<Loader2 className="h-3 w-3 animate-spin" />
177+
</div>
178+
) : filteredRemoteBranches.length === 0 ? (
179+
<div className="text-muted py-1.5 pl-2 text-[10px]">No branches</div>
180+
) : (
181+
<>
182+
{filteredRemoteBranches.map((branch) => (
183+
<button
184+
key={`${group.remote}/${branch}`}
185+
type="button"
186+
onClick={() => handleSelectRemoteBranch(group.remote, branch)}
187+
className="hover:bg-hover flex w-full items-center gap-1.5 rounded-sm px-2 py-1 font-mono text-[11px]"
188+
>
189+
<Check
190+
className={cn(
191+
"h-3 w-3 shrink-0",
192+
isRemoteBranchSelected(group.remote, branch)
193+
? "opacity-100"
194+
: "opacity-0"
195+
)}
196+
/>
197+
<span className="truncate">{branch}</span>
198+
</button>
199+
))}
200+
{group.truncated && !search && (
201+
<div className="text-muted px-2 py-1 text-[10px] italic">
202+
+more branches (use search)
203+
</div>
204+
)}
205+
</>
206+
)}
207+
</div>
208+
)}
209+
</div>
210+
);
211+
})}
212+
213+
{filteredLocalBranches.length > 0 && <div className="bg-border my-1 h-px" />}
214+
</>
215+
)}
216+
217+
{/* Local branches */}
218+
{props.isLoading && props.localBranches.length <= 1 ? (
219+
<div className="text-muted flex items-center justify-center py-2">
220+
<Loader2 className="h-3 w-3 animate-spin" />
221+
</div>
222+
) : filteredLocalBranches.length === 0 ? (
223+
<div className="text-muted py-2 text-center text-[10px]">No matching branches</div>
224+
) : (
225+
<>
226+
{filteredLocalBranches.map((branch) => (
227+
<button
228+
key={branch}
229+
type="button"
230+
onClick={() => handleSelectLocalBranch(branch)}
231+
className="hover:bg-hover flex w-full items-center gap-1.5 rounded-sm px-2 py-1 font-mono text-[11px]"
232+
>
233+
<Check
234+
className={cn(
235+
"h-3 w-3 shrink-0",
236+
props.selection?.kind === "local" && props.selection.branch === branch
237+
? "opacity-100"
238+
: "opacity-0"
239+
)}
240+
/>
241+
<span className="truncate">{branch}</span>
242+
</button>
243+
))}
244+
{props.localBranchesTruncated && !search && (
245+
<div className="text-muted px-2 py-1 text-[10px] italic">
246+
+more branches (use search)
247+
</div>
248+
)}
249+
</>
250+
)}
251+
</div>
252+
</PopoverContent>
253+
</Popover>
254+
);
255+
}

0 commit comments

Comments
 (0)