From 70d7cf30c235ebc4d633d32ad940af2b2f9f3776 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 3 Nov 2026 12:00:10 +0700 Subject: [PATCH] feat(git): search box for the branch switcher (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Search branches… box beside Switch branch narrows the local and remote lists to names containing the text (case-insensitive). One match is picked at once, Enter picks the first, Esc clears. The picked branch stays listed, so the select never loses its value. Closes #88 --- client/src/components/lanes/LaneGitBar.tsx | 56 +++++++++++++++++-- .../lanes/__tests__/LaneGitBar.test.tsx | 41 ++++++++++++++ client/src/i18n/locales/en/lanes.json | 3 + client/src/i18n/locales/vi/lanes.json | 3 + docs/LANES.md | 2 +- 5 files changed, 100 insertions(+), 5 deletions(-) diff --git a/client/src/components/lanes/LaneGitBar.tsx b/client/src/components/lanes/LaneGitBar.tsx index b19cd47a..b1df11d5 100644 --- a/client/src/components/lanes/LaneGitBar.tsx +++ b/client/src/components/lanes/LaneGitBar.tsx @@ -132,6 +132,8 @@ export function LaneGitBar({ const [remoteBranches, setRemoteBranches] = useState([]); const [worktrees, setWorktrees] = useState([]); const [picked, setPicked] = useState(""); + // Issue #88: typing narrows the switcher to the branches whose name holds it. + const [branchQuery, setBranchQuery] = useState(""); const [newBranch, setNewBranch] = useState(""); // Set when git needed a login for `action`; the form resends that action. const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null); @@ -160,6 +162,7 @@ export function LaneGitBar({ setRemoteBranches([]); setWorktrees([]); setPicked(""); + setBranchQuery(""); setNewBranch(""); setLogin(null); setPassword(""); @@ -176,6 +179,26 @@ export function LaneGitBar({ // A remote branch: switching creates its local tracking branch; it cannot // be deleted here or given a sibling worktree before that. const pickedRemote = remoteBranches.includes(picked); + // The switcher's lists, narrowed by the search box (case-insensitive, any + // part of the name). The picked branch always stays in its list, so the + // select never shows a value it has no option for. + const query = branchQuery.trim().toLowerCase(); + const matches = (b: string) => !query || b.toLowerCase().includes(query) || b === picked; + const shownBranches = (branches || []).filter(matches); + const shownRemote = remoteBranches.filter(matches); + const matchCount = query + ? [...(branches || []), ...remoteBranches].filter((b) => b.toLowerCase().includes(query)) + .length + : null; + const searchBranches = (text: string) => { + setBranchQuery(text); + const q = text.trim().toLowerCase(); + if (!q) return; + const hits = [...(branches || []), ...remoteBranches].filter((b) => + b.toLowerCase().includes(q) + ); + if (hits.length === 1) setPicked(hits[0]!); + }; const merging = !!git.merging; const run = async (action: Action, body?: RunBody) => { @@ -526,6 +549,27 @@ export function LaneGitBar({ {switching && (
+ searchBranches(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape" && branchQuery) { + e.stopPropagation(); + setBranchQuery(""); + } else if (isSubmitEnter(e)) { + e.preventDefault(); + const first = [...shownBranches, ...shownRemote].find( + (b) => b.toLowerCase().includes(query) && b !== git.branch + ); + if (first) setPicked(first); + } + }} + className="w-36 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" + />