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,
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"gitBar.pushHint": "git push -u origin HEAD",
|
||||
"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",
|
||||
|
||||
@@ -195,6 +195,9 @@
|
||||
"gitBar.pushHint": "git push -u origin HEAD",
|
||||
"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",
|
||||
|
||||
+1
-1
@@ -531,7 +531,7 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
|
||||
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
||||
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
||||
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
||||
| **Branch** → **Switch branch** | `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
||||
| **Branch** → **Switch branch** | the **Search branches…** box beside the list narrows it, local and remote, to names containing what you type (one match is picked at once, Enter picks the first, Esc clears — #88); `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
||||
| **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists |
|
||||
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
||||
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
||||
|
||||
Reference in New Issue
Block a user