feat(vcs): show favorite branches first in the Git Log branch filter (#3201)

The Git Log Branch filter now lists HEAD, then the favorite branches (local and remote, by name, marked with a star), then Local and Remote as collapsed groups that open with a click, Enter or Right and close with Left. A search still lists every match under Local and Remote. Nested branches are indented with the tree indent.

Co-authored-by: formiat <29867335+formiat@users.noreply.github.com>
This commit is contained in:
Alexander
2026-11-14 00:11:16 +08:00
committed by GitHub
co-authored by formiat
parent c8bdfa8edd
commit 8faedce175
5 changed files with 132 additions and 26 deletions
+89 -21
View File
@@ -1,34 +1,76 @@
import { useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
import type { Branch } from "@/bindings";
import { HighlightedText, Icon, List, Popup, SearchField, T, firstSelectable, fuzzyMatch, listNavigate } from "@/ui";
import { tx } from "@/platform/i18n";
import { sortLocal, sortRemote } from "./branches";
import { isFavoriteBranch, sortLocal, sortRemote } from "./branches";
import { loadFavoriteBranches, useFavoriteBranches } from "./branchOps";
import styles from "./GitLog.module.css";
type Group = "local" | "remote";
type Row =
| { type: "section"; key: string; label: string }
| { type: "ref"; key: string; value: string; label: string; current: boolean; matches: number[] | null };
| { type: "group"; key: string; group: Group; label: string; expanded: boolean }
| {
type: "ref";
key: string;
value: string;
label: string;
current: boolean;
favorite: boolean;
nested: boolean;
matches: number[] | null;
};
const ROW_HEIGHT = 24;
const MAX_LIST_HEIGHT = 320;
/** HEAD, then Local and Remote groups; groups without a match are left out. */
function buildRows(branches: readonly Branch[], query: string): Row[] {
/**
* IDEA's branch filter: HEAD, the favorite branches (★), then Local and Remote collapsed like its
* submenus. A query lists every match under Local and Remote instead.
*/
function buildRows(
branches: readonly Branch[],
favorites: readonly string[] | null,
query: string,
expanded: ReadonlySet<Group>,
): Row[] {
const q = query.trim();
const rows: Row[] = [];
const push = (value: string, label: string, current: boolean, out: Row[]) => {
const ref = (value: string, label: string, b: Branch | null, key: string, nested: boolean): Row[] => {
const matches = q ? fuzzyMatch(label, q) : null;
if (q && !matches) return;
out.push({ type: "ref", key: `ref:${value}`, value, label, current, matches });
if (q && !matches) return [];
const favorite = !!b && isFavoriteBranch(b, favorites);
return [{ type: "ref", key, value, label, current: !!b?.current, favorite, nested, matches }];
};
push("HEAD", tx("HEAD (Current Branch)"), false, rows);
const group = (key: string, label: string, list: readonly Branch[]) => {
const items: Row[] = [];
for (const b of list) push(b.name, b.name, b.current, items);
if (items.length) rows.push({ type: "section", key: `section:${key}`, label }, ...items);
};
group("local", tx("Local"), sortLocal(branches));
group("remote", tx("Remote"), sortRemote(branches));
rows.push(...ref("HEAD", tx("HEAD (Current Branch)"), null, "ref:HEAD", false));
const lists: Array<[Group, string, Branch[]]> = [
["local", tx("Local"), sortLocal(branches, favorites)],
["remote", tx("Remote"), sortRemote(branches, favorites)],
];
if (q) {
for (const [group, label, list] of lists) {
const items = list.flatMap((b) => ref(b.name, b.name, b, `ref:${b.name}`, false));
if (items.length) rows.push({ type: "section", key: `section:${group}`, label }, ...items);
}
return rows;
}
const favorite = lists
.flatMap(([, , list]) => list)
.filter((b) => isFavoriteBranch(b, favorites))
.sort((a, b) => a.name.localeCompare(b.name));
if (favorite.length)
rows.push(
{ type: "section", key: "section:favorites", label: tx("Favorites") },
...favorite.flatMap((b) => ref(b.name, b.name, b, `favorite:${b.name}`, false)),
);
for (const [group, label, list] of lists) {
if (!list.length) continue;
const open = expanded.has(group);
rows.push({ type: "group", key: `group:${group}`, group, label, expanded: open });
if (open) rows.push(...list.flatMap((b) => ref(b.name, b.name, b, `ref:${b.name}`, true)));
}
return rows;
}
@@ -51,15 +93,32 @@ export function BranchFilterPopup({
}) {
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const rows = useMemo(() => buildRows(branches, query), [branches, query]);
const [expanded, setExpanded] = useState<ReadonlySet<Group>>(new Set());
const favorites = useFavoriteBranches((s) => s.names);
useEffect(() => loadFavoriteBranches(), []);
const rows = useMemo(() => buildRows(branches, favorites, query, expanded), [branches, favorites, query, expanded]);
const activeIndex = rows[active] && !isSection(rows[active]!) ? active : firstSelectable(rows, isSection);
const toggle = (group: Group, open: boolean) =>
setExpanded((s) => {
const next = new Set(s);
if (open) next.add(group);
else next.delete(group);
return next;
});
const activate = (row: Row | undefined) => {
if (row?.type === "ref") onSelect(row.value);
else if (row?.type === "group") toggle(row.group, !row.expanded);
};
const onKeyDown = (e: KeyboardEvent) => {
const row = rows[activeIndex];
if (e.key === "Enter") {
e.preventDefault();
activate(rows[activeIndex]);
activate(row);
return;
}
if (row?.type === "group" && (e.key === "ArrowRight" || e.key === "ArrowLeft")) {
e.preventDefault();
toggle(row.group, e.key === "ArrowRight");
return;
}
const next = listNavigate(e.key, { items: rows, active: activeIndex, isSection, wrap: true });
@@ -113,10 +172,19 @@ export function BranchFilterPopup({
</span>
}
renderItem={(r) =>
r.type === "ref" ? (
<span className={styles.branchFilterRow}>
r.type === "group" ? (
<span className={styles.branchFilterRow} aria-expanded={r.expanded}>
<Icon
name={r.current ? "current-branch" : "branch"}
name={r.expanded ? "chevron-down" : "chevron-right"}
size={12}
className={styles.branchFilterIcon}
/>
<span className={styles.branchFilterLabel}>{r.label}</span>
</span>
) : r.type === "ref" ? (
<span className={clsx(styles.branchFilterRow, r.nested && styles.branchFilterNested)}>
<Icon
name={r.current ? "current-branch" : r.favorite ? "star" : "branch"}
size={14}
className={r.current ? styles.branchFilterCurrent : styles.branchFilterIcon}
/>
+4
View File
@@ -630,6 +630,10 @@
align-items: center;
gap: 8px;
}
/* A branch inside an opened Local / Remote group. */
.branchFilterNested {
padding-left: var(--tree-indent, 16px);
}
.branchFilterIcon {
flex: none;
color: var(--fg-subtle);
@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Branch } from "@/bindings";
import { BranchFilterPopup } from "../BranchFilterPopup";
import { useFavoriteBranches } from "../branchOps";
const branch = (name: string, extra: Partial<Branch> = {}) =>
({ name, remote: false, current: false, ...extra }) as Branch;
@@ -27,15 +28,46 @@ function renderPopup(onSelect = vi.fn()) {
return { input: screen.getByRole("textbox", { name: "Search branches" }), onSelect };
}
const rowTexts = () => screen.getAllByRole("option").map((o) => o.textContent);
afterEach(() => {
localStorage.clear();
useFavoriteBranches.setState({ key: "", names: null });
});
describe("branch filter popup", () => {
it("groups HEAD, local and remote branches and leaves out the remote HEAD", () => {
it("lists HEAD and the favorites, with Local and Remote collapsed until opened; no remote HEAD", () => {
renderPopup();
expect(screen.getByText("Local")).toBeTruthy();
expect(screen.getByText("Remote")).toBeTruthy();
// `main` is a favorite by default.
expect(screen.getByText("Favorites")).toBeTruthy();
expect(rowTexts()).toEqual(["HEAD (Current Branch)", "main", "Local", "Remote"]);
fireEvent.click(screen.getByText("Remote"));
expect(screen.getByText("Remote").closest("[aria-expanded]")).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("origin/develop")).toBeTruthy();
expect(screen.queryByText("origin/HEAD")).toBeNull();
});
it("shows the user's favorite branches, local and remote, under Favorites by name", () => {
localStorage.setItem("openidea.vcs.favoriteBranches:", JSON.stringify(["origin/develop", "feature/demo"]));
renderPopup();
expect(rowTexts()).toEqual(["HEAD (Current Branch)", "feature/demo", "origin/develop", "Local", "Remote"]);
const input = screen.getByRole("textbox", { name: "Search branches" });
// Right opens a group on the keyboard, Left closes it. Inside, the current branch, then favorites.
for (let i = 0; i < 3; i++) fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowRight" });
expect(rowTexts()).toEqual([
"HEAD (Current Branch)",
"feature/demo",
"origin/develop",
"Local",
"main",
"feature/demo",
"Remote",
]);
fireEvent.keyDown(input, { key: "ArrowLeft" });
expect(rowTexts()).toEqual(["HEAD (Current Branch)", "feature/demo", "origin/develop", "Local", "Remote"]);
});
it("searches case-insensitively and hides groups without a match", () => {
const { input, onSelect } = renderPopup();
fireEvent.change(input, { target: { value: "DEVELOP" } });
@@ -48,7 +80,7 @@ describe("branch filter popup", () => {
it("moves over section headers with the arrow keys", () => {
const { input, onSelect } = renderPopup();
// HEAD → (Local header skipped) → main
// HEAD → (Favorites header skipped) → main
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSelect).toHaveBeenLastCalledWith("main");
+1
View File
@@ -1,4 +1,5 @@
{
"Favorites": "Favorites",
"Add": "Add",
"Cancel": "Cancel",
"Command": "Command",
+1
View File
@@ -1,4 +1,5 @@
{
"Favorites": "收藏夹",
"Fix ESLint Problems": "修复 ESLint 问题",
"No ESLint problems to fix": "没有可修复的 ESLint 问题",
"Pin ({count})": "固定({count})",