History

Add inline branch creation to branch picker search

- let users create a new local branch directly from the branch combobox search - remove separate "New branch" form/button flow in `BranchToolbar` - add `inputClassName` support to `ComboboxInput` for native input styling control

Julius Marminge committed 9 months ago

Commit 9bd663b1 parent 58d926f2 files · +51 −73Browse files

Unified · Split

2 files
  1. apps/web/src/components/BranchToolbar.tsx +42 −72
  2. apps/web/src/components/ui/combobox.tsx +9 −1
apps/web/src/components/BranchToolbar.tsx+42−72
@@ -19,8 +19,7 @@ import { · 18 unmodified lines
1919 ComboboxPopup,
2020 ComboboxTrigger,
2121 } from "./ui/combobox";
22−import { ChevronDownIcon, PlusIcon } from "lucide-react";
23−import { InputGroup, InputGroupAddon, InputGroupInput } from "./ui/input-group";
22+import { ChevronDownIcon } from "lucide-react";
2423
2524 interface BranchToolbarProps {
2625 envMode: "local" | "worktree";
@@ -34,8 +33,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B · 7 unmodified lines
3433 const queryClient = useQueryClient();
3534
3635 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
37− const [isCreatingBranch, setIsCreatingBranch] = useState(false);
38− const [newBranchName, setNewBranchName] = useState("");
36+ const [branchQuery, setBranchQuery] = useState("");
3937
4038 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
4139 const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
@@ -51,6 +49,16 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B · 9 unmodified lines
5149 const branches = branchesQuery.data?.branches ?? [];
5250 const branchNames = branches.map((branch) => branch.name);
5351 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));
52+ const trimmedBranchQuery = branchQuery.trim();
53+ const canCreateBranch = envMode === "local" && trimmedBranchQuery.length > 0;
54+ const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);
55+ const createBranchItemValue = canCreateBranch
56+ ? `__create_new_branch__:${trimmedBranchQuery}`
57+ : null;
58+ const branchPickerItems =
59+ createBranchItemValue && !hasExactBranchMatch
60+ ? [...branchNames, createBranchItemValue]
61+ : branchNames;
5462 // ── Mutations ─────────────────────────────────────────────────────────
5563
5664 const checkoutMutation = useMutation({
@@ -86,8 +94,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B · 29 unmodified lines
8694
8795 useEffect(() => {
8896 if (isBranchMenuOpen) return;
89− setIsCreatingBranch(false);
90− setNewBranchName("");
97+ setBranchQuery("");
9198 }, [isBranchMenuOpen]);
9299
93100 // ── Helpers ───────────────────────────────────────────────────────────
@@ -140,15 +147,14 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B · 46 unmodified lines
140147 });
141148 };
142149
143− const createBranch = () => {
144− const name = newBranchName.trim();
145− if (!api || !activeThreadId || !branchCwd || !name) return;
150+ const createBranch = (rawName: string) => {
151+ const name = rawName.trim();
152+ if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return;
146153 createBranchMutation.mutate(name, {
147154 onSuccess: () => {
148155 setThreadError(null);
149156 setThreadBranch(name, activeWorktreePath);
150− setNewBranchName("");
151− setIsCreatingBranch(false);
157+ setBranchQuery("");
152158 setIsBranchMenuOpen(false);
153159 },
154160 });
@@ -177,7 +183,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B · 22 unmodified lines
177183 </div>
178184
179185 <Combobox
180− items={branchNames}
186+ items={branchPickerItems}
181187 autoHighlight
182188 onOpenChange={(open) => setIsBranchMenuOpen(open)}
183189 open={isBranchMenuOpen}
@@ -198,20 +204,35 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B · 14 unmodified lines
198204 <ChevronDownIcon />
199205 </ComboboxTrigger>
200206 <ComboboxPopup align="end" side="top" className="w-64">
201− <div className="border-b">
207+ <div className="border-b p-1">
202208 <ComboboxInput
203− className="rounded-b-none before:rounded-b-none [&_input]:font-sans"
209+ className="[&_input]:font-sans rounded-md"
210+ inputClassName="ring-0"
204211 placeholder="Search branches..."
205− showClear
206212 showTrigger={false}
207213 size="sm"
214+ value={branchQuery}
215+ onChange={(event) => setBranchQuery(event.target.value)}
208216 />
209217 </div>
210218 <ComboboxEmpty>No branches found.</ComboboxEmpty>
211219
212220 <ComboboxList className="max-h-56">
213− {(branchName) => {
214− const branch = branchByName.get(branchName);
221+ {(itemValue) => {
222+ if (createBranchItemValue && itemValue === createBranchItemValue) {
223+ return (
224+ <ComboboxItem
225+ hideIndicator
226+ key={itemValue}
227+ value={itemValue}
228+ onClick={() => createBranch(trimmedBranchQuery)}
229+ >
230+ <span className="truncate">Create new branch "{trimmedBranchQuery}"</span>
231+ </ComboboxItem>
232+ );
233+ }
234+
235+ const branch = branchByName.get(itemValue);
215236 if (!branch) return null;
216237
217238 const hasSecondaryWorktree =
@@ -219,15 +240,15 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B · 1 unmodified line
219240 return (
220241 <ComboboxItem
221242 hideIndicator
222− key={branchName}
223− value={branchName}
243+ key={itemValue}
244+ value={itemValue}
224245 className={
225− branchName === activeThread.branch ? "bg-accent text-foreground" : undefined
246+ itemValue === activeThread.branch ? "bg-accent text-foreground" : undefined
226247 }
227248 onClick={() => selectBranch(branch)}
228249 >
229250 <div className="flex w-full items-center justify-between gap-2">
230− <span className="truncate">{branchName}</span>
251+ <span className="truncate">{itemValue}</span>
231252 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
232253 <span className="shrink-0 text-[10px] text-muted-foreground/45">
233254 {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"}
@@ -238,57 +259,6 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B · 4 unmodified lines
238259 );
239260 }}
240261 </ComboboxList>
241− {envMode === "local" && (
242− <>
243− {isCreatingBranch ? (
244− <form
245− onSubmit={(event) => {
246− event.preventDefault();
247− createBranch();
248− }}
249− >
250− <InputGroup className="rounded-t-none before:rounded-t-none">
251− <InputGroupInput
252− className="[&_input]:font-sans"
253− size="sm"
254− placeholder="branch-name"
255− type="text"
256− value={newBranchName}
257− onChange={(event) => setNewBranchName(event.target.value)}
258− onKeyDown={(event) => {
259− if (event.key === "Escape") {
260− event.stopPropagation();
261− setIsCreatingBranch(false);
262− setNewBranchName("");
263− }
264− }}
265− autoFocus
266− />
267− <InputGroupAddon align="inline-end">
268− <Button
269− size="xs"
270− variant="secondary"
271− type="submit"
272− disabled={!newBranchName.trim() || createBranchMutation.isPending}
273− >
274− Create
275− </Button>
276− </InputGroupAddon>
277− </InputGroup>
278− </form>
279− ) : (
280− <Button
281− size="sm"
282− className="w-full justify-start h-9 rounded-t-none rounded-b-lg before:rounded-t-none"
283− variant="outline"
284− onClick={() => setIsCreatingBranch(true)}
285− >
286− <PlusIcon />
287− New branch
288− </Button>
289− )}
290− </>
291− )}
292262 </ComboboxPopup>
293263 </Combobox>
294264 </div>
apps/web/src/components/ui/combobox.tsx+9−1
@@ -54,12 +54,14 @@ function ComboboxChipsInput({ · 53 unmodified lines
5454
5555 function ComboboxInput({
5656 className,
57+ inputClassName,
5758 showTrigger = true,
5859 showClear = false,
5960 startAddon,
6061 size,
6162 ...props
6263 }: Omit<ComboboxPrimitive.Input.Props, "size"> & {
64+ inputClassName?: string;
6365 showTrigger?: boolean;
6466 showClear?: boolean;
6567 startAddon?: React.ReactNode;
@@ -89,7 +91,13 @@ function ComboboxInput({ · 23 unmodified lines
8991 className,
9092 )}
9193 data-slot="combobox-input"
92− render={<Input className="has-disabled:opacity-100" nativeInput size={sizeValue} />}
94+ render={
95+ <Input
96+ className={cn("has-disabled:opacity-100", inputClassName)}
97+ nativeInput
98+ size={sizeValue}
99+ />
100+ }
93101 {...props}
94102 />
95103 {showTrigger && (

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t