Skip to content

Commit 5dbfa7a

Browse files
authored
feat: add branch search and validation in header popover (#229)
1 parent 081939c commit 5dbfa7a

2 files changed

Lines changed: 144 additions & 44 deletions

File tree

src/features/app/components/MainHeader.tsx

Lines changed: 122 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,7 @@ export function MainHeader({
6565
}: MainHeaderProps) {
6666
const [menuOpen, setMenuOpen] = useState(false);
6767
const [infoOpen, setInfoOpen] = useState(false);
68-
const [isCreating, setIsCreating] = useState(false);
69-
const [newBranch, setNewBranch] = useState("");
68+
const [branchQuery, setBranchQuery] = useState("");
7069
const [error, setError] = useState<string | null>(null);
7170
const [copyFeedback, setCopyFeedback] = useState(false);
7271
const copyTimeoutRef = useRef<number | null>(null);
@@ -76,7 +75,50 @@ export function MainHeader({
7675
const renameConfirmRef = useRef<HTMLButtonElement | null>(null);
7776
const renameOnCancel = worktreeRename?.onCancel;
7877

79-
const recentBranches = branches.slice(0, 12);
78+
const recentBranches = branches;
79+
const trimmedQuery = branchQuery.trim();
80+
const lowercaseQuery = trimmedQuery.toLowerCase();
81+
const filteredBranches =
82+
trimmedQuery.length > 0
83+
? recentBranches.filter((branch) =>
84+
branch.name.toLowerCase().includes(lowercaseQuery),
85+
)
86+
: recentBranches.slice(0, 12);
87+
const exactMatch = trimmedQuery
88+
? recentBranches.find((branch) => branch.name === trimmedQuery) ?? null
89+
: null;
90+
const canCreate = trimmedQuery.length > 0 && !exactMatch;
91+
const branchValidationMessage = (() => {
92+
if (trimmedQuery.length === 0) {
93+
return null;
94+
}
95+
if (trimmedQuery === "." || trimmedQuery === "..") {
96+
return "Branch name cannot be '.' or '..'.";
97+
}
98+
if (/\s/.test(trimmedQuery)) {
99+
return "Branch name cannot contain spaces.";
100+
}
101+
if (trimmedQuery.startsWith("/") || trimmedQuery.endsWith("/")) {
102+
return "Branch name cannot start or end with '/'.";
103+
}
104+
if (trimmedQuery.endsWith(".lock")) {
105+
return "Branch name cannot end with '.lock'.";
106+
}
107+
if (trimmedQuery.includes("..")) {
108+
return "Branch name cannot contain '..'.";
109+
}
110+
if (trimmedQuery.includes("@{")) {
111+
return "Branch name cannot contain '@{'.";
112+
}
113+
const invalidChars = ["~", "^", ":", "?", "*", "[", "\\"];
114+
if (invalidChars.some((char) => trimmedQuery.includes(char))) {
115+
return "Branch name contains invalid characters.";
116+
}
117+
if (trimmedQuery.endsWith(".")) {
118+
return "Branch name cannot end with '.'.";
119+
}
120+
return null;
121+
})();
80122
const resolvedWorktreePath = worktreePath ?? workspace.path;
81123
const relativeWorktreePath =
82124
parentPath && resolvedWorktreePath.startsWith(`${parentPath}/`)
@@ -95,8 +137,7 @@ export function MainHeader({
95137
if (!menuContains && !infoContains) {
96138
setMenuOpen(false);
97139
setInfoOpen(false);
98-
setIsCreating(false);
99-
setNewBranch("");
140+
setBranchQuery("");
100141
setError(null);
101142
}
102143
};
@@ -313,55 +354,93 @@ export function MainHeader({
313354
data-tauri-drag-region="false"
314355
>
315356
<div className="branch-actions">
316-
{!isCreating ? (
317-
<button
318-
type="button"
319-
className="branch-action"
320-
onClick={() => setIsCreating(true)}
321-
data-tauri-drag-region="false"
322-
>
323-
<span className="branch-action-icon">+</span>
324-
Create branch
325-
</button>
326-
) : (
327-
<div className="branch-create">
328-
<input
329-
value={newBranch}
330-
onChange={(event) => setNewBranch(event.target.value)}
331-
placeholder="new-branch-name"
332-
className="branch-input"
333-
autoFocus
334-
data-tauri-drag-region="false"
335-
/>
336-
<button
337-
type="button"
338-
className="branch-create-button"
339-
onClick={async () => {
340-
const name = newBranch.trim();
341-
if (!name) {
342-
return;
357+
<div className="branch-search">
358+
<input
359+
value={branchQuery}
360+
onChange={(event) => {
361+
setBranchQuery(event.target.value);
362+
setError(null);
363+
}}
364+
onKeyDown={async (event) => {
365+
if (event.key !== "Enter") {
366+
return;
367+
}
368+
event.preventDefault();
369+
if (branchValidationMessage) {
370+
setError(branchValidationMessage);
371+
return;
372+
}
373+
if (canCreate) {
374+
try {
375+
await onCreateBranch(trimmedQuery);
376+
setMenuOpen(false);
377+
setBranchQuery("");
378+
setError(null);
379+
} catch (err) {
380+
setError(
381+
err instanceof Error ? err.message : String(err),
382+
);
343383
}
384+
return;
385+
}
386+
if (exactMatch && exactMatch.name !== branchName) {
344387
try {
345-
await onCreateBranch(name);
388+
await onCheckoutBranch(exactMatch.name);
346389
setMenuOpen(false);
347-
setIsCreating(false);
348-
setNewBranch("");
390+
setBranchQuery("");
349391
setError(null);
350392
} catch (err) {
351393
setError(
352394
err instanceof Error ? err.message : String(err),
353395
);
354396
}
355-
}}
356-
data-tauri-drag-region="false"
357-
>
358-
Create + checkout
359-
</button>
397+
}
398+
}}
399+
placeholder="Search or create branch"
400+
className="branch-input"
401+
autoFocus
402+
data-tauri-drag-region="false"
403+
aria-label="Search branches"
404+
/>
405+
<button
406+
type="button"
407+
className="branch-create-button"
408+
disabled={!canCreate || Boolean(branchValidationMessage)}
409+
onClick={async () => {
410+
if (branchValidationMessage) {
411+
setError(branchValidationMessage);
412+
return;
413+
}
414+
if (!canCreate) {
415+
return;
416+
}
417+
try {
418+
await onCreateBranch(trimmedQuery);
419+
setMenuOpen(false);
420+
setBranchQuery("");
421+
setError(null);
422+
} catch (err) {
423+
setError(
424+
err instanceof Error ? err.message : String(err),
425+
);
426+
}
427+
}}
428+
data-tauri-drag-region="false"
429+
>
430+
Create
431+
</button>
432+
</div>
433+
{branchValidationMessage && (
434+
<div className="branch-error">{branchValidationMessage}</div>
435+
)}
436+
{canCreate && !branchValidationMessage && (
437+
<div className="branch-create-hint">
438+
Create branch “{trimmedQuery}
360439
</div>
361440
)}
362441
</div>
363442
<div className="branch-list" role="none">
364-
{recentBranches.map((branch) => (
443+
{filteredBranches.map((branch) => (
365444
<button
366445
key={branch.name}
367446
type="button"
@@ -375,8 +454,7 @@ export function MainHeader({
375454
try {
376455
await onCheckoutBranch(branch.name);
377456
setMenuOpen(false);
378-
setIsCreating(false);
379-
setNewBranch("");
457+
setBranchQuery("");
380458
setError(null);
381459
} catch (err) {
382460
setError(
@@ -390,7 +468,7 @@ export function MainHeader({
390468
{branch.name}
391469
</button>
392470
))}
393-
{recentBranches.length === 0 && (
471+
{filteredBranches.length === 0 && (
394472
<div className="branch-empty">No branches found</div>
395473
)}
396474
</div>

src/styles/main.css

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,16 @@
609609
gap: 6px;
610610
}
611611

612+
.branch-search {
613+
display: flex;
614+
gap: 6px;
615+
align-items: center;
616+
}
617+
618+
.branch-search .branch-input {
619+
flex: 1;
620+
}
621+
612622

613623
.branch-action {
614624
display: inline-flex;
@@ -673,6 +683,18 @@
673683
background: var(--surface-control-hover);
674684
}
675685

686+
.branch-create-button:disabled {
687+
cursor: not-allowed;
688+
opacity: 0.6;
689+
background: var(--surface-card);
690+
}
691+
692+
.branch-create-hint {
693+
font-size: 11px;
694+
color: var(--text-faint);
695+
padding: 2px 8px 0;
696+
}
697+
676698
.branch-list {
677699
display: flex;
678700
flex-direction: column;

0 commit comments

Comments
 (0)