feat(select): long lists become a combobox; the branch switcher uses it (#88)

Select opens with a focused search box when it has more than 8 options
(or searchable): typing narrows by label/value, ↑/↓ move, Enter picks,
Esc closes; role=combobox drives the listbox via aria-activedescendant,
and options may carry a group heading. The git bar's native branch
select (and the separate search box added for #88) is replaced by it,
remote branches under their own heading.
This commit is contained in:
2026-11-03 13:25:16 +07:00
parent 2a83551eee
commit cd25791160
10 changed files with 287 additions and 197 deletions
+129 -52
View File
@@ -15,6 +15,14 @@
* the next option whose label starts with it. Focus returns to the trigger on
* every close except Tab.
*
* ## Search — a combobox once the list is long
* With more than `SEARCH_FROM` options (or `searchable`), the popup opens with
* a search box on top, focused: typing narrows the list to options whose
* label or value contains the text (case-insensitive), ↑/↓ move, Enter picks,
* Escape closes. The box is `role="combobox"` controlling the listbox through
* `aria-activedescendant`. Options may carry a `group`: a heading is drawn
* where the group changes.
*
* ## Consumers
* Used on the Run Claude page and webhook settings form wherever a compact
* enum picker is needed.
@@ -73,7 +81,11 @@
* ----------------------------------------------------------------------------- */
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { ChevronDown, Check } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Check, Search } from "lucide-react";
/** A list longer than this gets a search box (unless `searchable` says otherwise). */
export const SEARCH_FROM = 8;
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
@@ -95,6 +107,8 @@ export interface SelectOption<T extends string> {
icon?: ReactNode;
/** Draw a rule above this option: it starts a group of its own. */
divider?: boolean;
/** The group this option belongs to: a heading is drawn where it changes. */
group?: string;
}
/** Props for {@link Select}. */
@@ -111,6 +125,10 @@ export interface SelectProps<T extends string> {
variant?: "field" | "ghost";
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
label?: string;
/** Show the search box: default only for lists longer than `SEARCH_FROM`. */
searchable?: boolean;
/** The search box's name and placeholder (default: "Search…"). */
searchLabel?: string;
}
/**
@@ -124,15 +142,26 @@ export function Select<T extends string>({
disabled,
variant = "field",
label,
searchable,
searchLabel,
}: SelectProps<T>) {
const { t } = useTranslation("common");
const ghost = variant === "ghost";
const canSearch = searchable ?? options.length > SEARCH_FROM;
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [openUp, setOpenUp] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement | null>(null);
const typed = useRef({ text: "", at: 0 });
// The options shown: all of them, or those the search text matches.
const q = canSearch ? query.trim().toLowerCase() : "";
const shown = q
? options.filter((o) => `${o.label} ${o.value}`.toLowerCase().includes(q))
: options;
const id = useId();
const optionId = (idx: number) => `${id}-opt-${idx}`;
@@ -150,6 +179,7 @@ export function Select<T extends string>({
// above the trigger (288 px = `max-h-72`), and move focus into the list.
useEffect(() => {
if (!open) return;
setQuery("");
const idx = options.findIndex((o) => o.value === value);
setActive(idx >= 0 ? idx : 0);
if (buttonRef.current) {
@@ -159,14 +189,14 @@ export function Select<T extends string>({
);
setOpenUp(below < 288 && above > below);
}
listRef.current?.focus({ preventScroll: true });
(canSearch ? searchRef.current : listRef.current)?.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
}, [open]);
useEffect(() => {
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
}, [open, active]);
}, [open, active, q]);
const close = (refocus = true) => {
setOpen(false);
@@ -185,8 +215,12 @@ export function Select<T extends string>({
}
};
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const last = options.length - 1;
const onListKey = (e: React.KeyboardEvent<HTMLElement>) => {
const last = shown.length - 1;
// In the search box, keys that edit text stay the box's own.
const inSearch = e.currentTarget === searchRef.current;
if (inSearch && (e.key === " " || e.key === "Home" || e.key === "End")) return;
if (inSearch && e.key === "Enter" && e.nativeEvent.isComposing) return;
const move: Record<string, (a: number) => number> = {
ArrowDown: (a) => Math.min(last, a + 1),
ArrowUp: (a) => Math.max(0, a - 1),
@@ -200,7 +234,7 @@ export function Select<T extends string>({
setActive(move[e.key]!);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const opt = options[active];
const opt = shown[active];
if (opt) choose(opt);
} else if (e.key === "Escape") {
// Stop here: a Select inside a dialog must not close the dialog too.
@@ -209,7 +243,7 @@ export function Select<T extends string>({
close();
} else if (e.key === "Tab") {
close(false);
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
} else if (!inSearch && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
// character cycles through the options that start with it.
const now = Date.now();
@@ -258,57 +292,100 @@ export function Select<T extends string>({
</button>
{open && (
<div
ref={listRef}
id={`${id}-list`}
role="listbox"
tabIndex={-1}
aria-label={label}
aria-activedescendant={options[active] ? optionId(active) : undefined}
onKeyDown={onListKey}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
}}
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} flex max-h-72 flex-col overflow-hidden rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 ${
openUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
{options.map((opt, idx) => {
const isSelected = opt.value === value;
const isActive = idx === active;
return (
<div
key={opt.value || "__default__"}
id={optionId(idx)}
role="option"
aria-selected={isSelected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(opt)}
onMouseEnter={() => setActive(idx)}
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
opt.divider ? "mt-1 border-t border-border pt-2" : ""
} ${
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
}`}
>
<div className="flex items-center gap-2">
{opt.icon}
<span
className={`text-[11px] flex-1 truncate ${
isSelected ? "text-accent font-medium" : "text-fg-secondary"
{canSearch && (
<div className="flex shrink-0 items-center gap-1.5 border-b border-border px-2.5 py-1.5">
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
<input
ref={searchRef}
role="combobox"
aria-label={searchLabel ?? t("select.search")}
aria-expanded
aria-controls={`${id}-list`}
aria-autocomplete="list"
aria-activedescendant={shown[active] ? optionId(active) : undefined}
placeholder={searchLabel ?? t("select.search")}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
onKeyDown={onListKey}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget as Node | null))
setOpen(false);
}}
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none"
/>
</div>
)}
<div
ref={listRef}
id={`${id}-list`}
role="listbox"
tabIndex={-1}
aria-label={label}
aria-activedescendant={!canSearch && shown[active] ? optionId(active) : undefined}
onKeyDown={onListKey}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
}}
className="min-h-0 flex-1 overflow-auto py-1 outline-none"
>
{canSearch && shown.length === 0 && (
<div className="px-3 py-1.5 text-[11px] text-fg-muted">{t("select.noMatch")}</div>
)}
{shown.map((opt, idx) => {
const isSelected = opt.value === value;
const isActive = idx === active;
const heading = opt.group && opt.group !== shown[idx - 1]?.group ? opt.group : null;
return (
<div key={opt.value || "__default__"} role="none">
{heading && (
<div
role="presentation"
className="mt-1 border-t border-border px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-fg-muted"
>
{heading}
</div>
)}
<div
id={optionId(idx)}
role="option"
aria-selected={isSelected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(opt)}
onMouseEnter={() => setActive(idx)}
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
opt.divider ? "mt-1 border-t border-border pt-2" : ""
} ${
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
}`}
>
{opt.label}
</span>
{isSelected && (
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
)}
<div className="flex items-center gap-2">
{opt.icon}
<span
className={`text-[11px] flex-1 truncate ${
isSelected ? "text-accent font-medium" : "text-fg-secondary"
}`}
>
{opt.label}
</span>
{isSelected && (
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
)}
</div>
{opt.hint && (
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
)}
</div>
</div>
{opt.hint && (
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
)}
</div>
);
})}
);
})}
</div>
</div>
)}
</div>
@@ -105,7 +105,61 @@ describe("Select (listbox button)", () => {
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
vi.stubGlobal("innerHeight", 2000);
fireEvent.click(button);
expect(screen.getByRole("listbox").className).toContain("bottom-full");
expect(screen.getByRole("listbox").parentElement!.className).toContain("bottom-full");
vi.unstubAllGlobals();
});
it("turns a long list into a combobox: type to narrow, Enter picks, groups keep headings", async () => {
const onChange = vi.fn();
const options = [
{ value: "", label: "Switch branch…" },
...["develop", "main", "feat/campaign-pause", "fix/campaign-step", "chore/a", "chore/b"].map(
(v) => ({ value: v, label: v })
),
...["origin/feat/campaign-statistic", "origin/x", "origin/y"].map((v) => ({
value: v,
label: v,
group: "On the remote",
})),
];
render(<Select value="" onChange={onChange} options={options} label="Switch branch…" />);
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
const search = screen.getByRole("combobox", { name: "Search…" });
expect(search).toHaveFocus();
expect(screen.getByText("On the remote")).toBeInTheDocument();
fireEvent.change(search, { target: { value: "CAMPAIGN" } });
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
"feat/campaign-pause",
"fix/campaign-step",
"origin/feat/campaign-statistic",
]);
fireEvent.keyDown(search, { key: "ArrowDown" });
fireEvent.keyDown(search, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("fix/campaign-step");
expect(screen.queryByRole("listbox")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
fireEvent.change(screen.getByRole("combobox"), { target: { value: "zzz" } });
expect(screen.getByText("No match")).toBeInTheDocument();
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" });
expect(screen.queryByRole("listbox")).toBeNull();
});
it("keeps a short list a plain listbox (no search box)", () => {
render(
<Select
value="a"
onChange={vi.fn()}
options={[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
]}
label="Pick"
/>
);
fireEvent.click(screen.getByRole("button", { name: "Pick" }));
expect(screen.queryByRole("combobox")).toBeNull();
expect(screen.getByRole("listbox")).toHaveFocus();
});
});
+27 -75
View File
@@ -57,6 +57,7 @@ import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
import { restoreShelf } from "./LaneShelves";
import { BranchesButton } from "./git/BranchesButton";
import { Select } from "../Select";
type Action =
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
@@ -132,8 +133,6 @@ 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);
@@ -162,7 +161,6 @@ export function LaneGitBar({
setRemoteBranches([]);
setWorktrees([]);
setPicked("");
setBranchQuery("");
setNewBranch("");
setLogin(null);
setPassword("");
@@ -179,26 +177,6 @@ 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) => {
@@ -549,58 +527,32 @@ 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}
disabled={!!busy}
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="">
{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}>
{b}
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
</option>
);
})}
{shownRemote.length > 0 && (
<optgroup label={t("gitBar.remoteBranches")}>
{shownRemote.map((b) => (
<option key={`remote:${b}`} value={b}>
{b}
</option>
))}
</optgroup>
)}
</select>
{/* A combobox once the list is long (Select's search box, #88):
type to narrow the local and remote branches. */}
<div className="min-w-[8rem] flex-1 font-mono">
<Select
label={t("gitBar.switchLabel")}
searchLabel={t("gitBar.branchSearch")}
searchable
value={picked}
disabled={!!busy}
onChange={setPicked}
options={[
{ value: "", label: t("gitBar.switchLabel") },
...(branches || []).map((b) => {
const held = heldBy.get(b);
const note =
b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : "";
return { value: b, label: `${b}${note}` };
}),
...remoteBranches.map((b) => ({
value: b,
label: b,
group: t("gitBar.remoteBranches"),
})),
]}
/>
</div>
<Btn
label={t("gitBar.switchApply")}
busy={busy === "switch"}
@@ -171,21 +171,23 @@ beforeEach(() => {
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
vi.mocked(h.pick).mockReset();
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
available: true,
op,
commits: shas.map((sha) => ({
sha,
short: sha.slice(0, 7),
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
})),
branch: "feat/thing",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(h.pickPlan)
.mockReset()
.mockImplementation(async (_t, op, shas) => ({
available: true,
op,
commits: shas.map((sha) => ({
sha,
short: sha.slice(0, 7),
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
})),
branch: "feat/thing",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(h.fileHistory)
.mockReset()
.mockResolvedValue({
@@ -538,7 +540,8 @@ describe("LaneChangesPanel", () => {
await screen.findByTitle("only-on-thing.ts");
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
await user.click(screen.getByRole("option", { name: "other" }));
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
@@ -1344,9 +1347,7 @@ describe("LaneChangesPanel", () => {
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
await user.click(await screen.findByTestId("pick-confirm"));
await waitFor(() =>
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [
"a".repeat(40),
])
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", ["a".repeat(40)])
);
});
@@ -13,13 +13,22 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneGitBar } from "../LaneGitBar";
import { api } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
import type { LaneGitFacts } from "../../../lib/types";
/** Picks `name` in the branch switcher — a Select combobox (#88). */
async function pickBranch(user: ReturnType<typeof userEvent.setup>, name: string) {
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await user.click(
within(screen.getByRole("listbox")).getByRole("option", { name: new RegExp(`^${esc}( —|$)`) })
);
}
// A merge asks through the themed confirm (its host lives in App): yes.
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/api", () => ({
@@ -210,7 +219,7 @@ describe("LaneGitBar", () => {
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
// Nothing is sent before the user picks what happens to the changes.
expect(api.lanes.gitAction).not.toHaveBeenCalled();
@@ -235,7 +244,7 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
@@ -258,7 +267,7 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
await waitFor(() =>
@@ -291,7 +300,7 @@ describe("LaneGitBar", () => {
const onChanged = vi.fn();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
@@ -319,8 +328,8 @@ describe("LaneGitBar", () => {
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…");
await user.selectOptions(select, "development");
await screen.findByRole("button", { name: "Switch branch…" });
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
@@ -332,12 +341,12 @@ describe("LaneGitBar", () => {
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/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
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 () => {
it("opens the branch switcher as a combobox: type to narrow, Enter picks (#88)", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/campaign-pause", "fix/campaign-step", "main"],
@@ -348,34 +357,24 @@ describe("LaneGitBar", () => {
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([
const trigger = await screen.findByRole("button", { name: "Switch branch…" });
await user.click(trigger);
const search = screen.getByRole("combobox", { name: "Search branches…" });
expect(search).toHaveFocus();
await user.keyboard("CAMPAIGN");
expect(screen.getAllByRole("option").map((o) => o.textContent)).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);
expect(trigger).toHaveTextContent("feat/campaign-pause");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
branch: "feat/campaign-pause",
})
);
});
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
@@ -396,11 +395,12 @@ describe("LaneGitBar", () => {
/>
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
expect(
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
screen.getByText("On the remote — switching creates the local branch")
).toBeInTheDocument();
await user.selectOptions(select, "origin/feat/new");
await user.keyboard("{Escape}");
await pickBranch(user, "origin/feat/new");
// Not a local branch yet: nothing to delete, no worktree to start on it.
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
@@ -436,7 +436,7 @@ describe("LaneGitBar", () => {
})
);
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "New workspace here" }));
expect(onNewLane).toHaveBeenCalledWith("development");
});
@@ -454,18 +454,18 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await screen.findByRole("button", { name: "Switch branch…" });
const newLane = () => screen.getByRole("button", { name: "New workspace here" });
// This lane's own branch: its worktree is this lane.
await user.selectOptions(select, "feat/thing");
await pickBranch(user, "feat/thing");
expect(newLane()).toBeDisabled();
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
await user.selectOptions(select, "development");
await pickBranch(user, "development");
expect(newLane()).toBeDisabled();
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
await user.selectOptions(select, "feat/free");
await pickBranch(user, "feat/free");
expect(newLane()).toBeEnabled();
});
@@ -484,13 +484,15 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
expect(await screen.findByRole("button", { name: "Switch branch…" })).toHaveTextContent(
"Switch branch…"
);
});
it("sends one branch-create for a double Enter", async () => {
@@ -517,7 +519,7 @@ describe("LaneGitBar", () => {
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await screen.findByRole("button", { name: "Switch branch…" });
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
// Lane 3's answer lands late: its worktree held `development`.
resolveOld({
@@ -526,7 +528,7 @@ describe("LaneGitBar", () => {
current: "feat/thing",
worktrees: [{ path: "/repo/dev", branch: "development" }],
});
await user.selectOptions(select, "development");
await pickBranch(user, "development");
const newLane = screen.getByRole("button", { name: "New workspace here" });
expect(newLane).toBeEnabled();
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
@@ -613,7 +615,7 @@ describe("merge (issue #45)", () => {
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/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Merge into current" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
+4
View File
@@ -223,5 +223,9 @@
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
}
},
"select": {
"search": "Search…",
"noMatch": "No match"
}
}
-2
View File
@@ -196,8 +196,6 @@
"gitBar.switch": "Branch",
"gitBar.switchApply": "Switch branch",
"gitBar.branchSearch": "Search branches…",
"gitBar.branchMatches_one": "{{count}} matching branch",
"gitBar.branchMatches_other": "{{count}} matching branches",
"gitBar.switchCurrent": "current",
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
+4
View File
@@ -223,5 +223,9 @@
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
}
},
"select": {
"search": "Tìm…",
"noMatch": "Không có kết quả"
}
}
-2
View File
@@ -196,8 +196,6 @@
"gitBar.switch": "Nhánh",
"gitBar.switchApply": "Chuyển nhánh",
"gitBar.branchSearch": "Tìm nhánh…",
"gitBar.branchMatches_one": "{{count}} nhánh khớp",
"gitBar.branchMatches_other": "{{count}} nhánh khớp",
"gitBar.switchCurrent": "hiện tại",
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
"gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước",