feat(git): search box for the branch switcher (#88)
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
This commit is contained in:
@@ -132,6 +132,8 @@ export function LaneGitBar({
|
||||
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
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 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
type="search"
|
||||
aria-label={t("gitBar.branchSearch")}
|
||||
placeholder={t("gitBar.branchSearch")}
|
||||
value={branchQuery}
|
||||
disabled={!!busy}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitBar.switchLabel")}
|
||||
value={picked}
|
||||
@@ -533,8 +577,12 @@ export function LaneGitBar({
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
||||
>
|
||||
<option value="">{t("gitBar.switchLabel")}</option>
|
||||
{(branches || []).map((b) => {
|
||||
<option value="">
|
||||
{matchCount === null
|
||||
? t("gitBar.switchLabel")
|
||||
: t("gitBar.branchMatches", { count: matchCount })}
|
||||
</option>
|
||||
{shownBranches.map((b) => {
|
||||
const held = heldBy.get(b);
|
||||
return (
|
||||
<option key={b} value={b}>
|
||||
@@ -543,9 +591,9 @@ export function LaneGitBar({
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{remoteBranches.length > 0 && (
|
||||
{shownRemote.length > 0 && (
|
||||
<optgroup label={t("gitBar.remoteBranches")}>
|
||||
{remoteBranches.map((b) => (
|
||||
{shownRemote.map((b) => (
|
||||
<option key={`remote:${b}`} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
|
||||
@@ -337,6 +337,47 @@ describe("LaneGitBar", () => {
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("narrows the branch list as you type, picks a single match and Enter's first one (#88)", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/campaign-pause", "fix/campaign-step", "main"],
|
||||
remotes: ["origin/feat/campaign-statistic", "origin/feature/thienpt/unit_test"],
|
||||
current: "development",
|
||||
worktrees: [],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
const search = screen.getByLabelText("Search branches…");
|
||||
const options = () =>
|
||||
Array.from(select.querySelectorAll("option"))
|
||||
.map((o) => o.value)
|
||||
.filter(Boolean);
|
||||
|
||||
await user.type(search, "CAMPAIGN");
|
||||
expect(options()).toEqual([
|
||||
"feat/campaign-pause",
|
||||
"fix/campaign-step",
|
||||
"origin/feat/campaign-statistic",
|
||||
]);
|
||||
expect(select.querySelector("option")).toHaveTextContent("3 matching branches");
|
||||
expect(select).toHaveValue("");
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(select).toHaveValue("feat/campaign-pause");
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, "unit_t");
|
||||
// One match: picked at once (the earlier pick stays listed until replaced).
|
||||
expect(select).toHaveValue("origin/feature/thienpt/unit_test");
|
||||
expect(options()).toEqual(["origin/feature/thienpt/unit_test"]);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(search).toHaveValue("");
|
||||
expect(options()).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
|
||||
Reference in New Issue
Block a user