From 8faedce175cf6276fd970b125fc86c281070736a Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 13 Nov 2026 13:11:16 -0300 Subject: [PATCH] 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> --- src/features/vcs/BranchFilterPopup.tsx | 110 ++++++++++++++---- src/features/vcs/GitLog.module.css | 4 + .../vcs/__tests__/branchFilterPopup.test.tsx | 42 ++++++- src/platform/locales/text/en.json | 1 + src/platform/locales/text/zh.json | 1 + 5 files changed, 132 insertions(+), 26 deletions(-) diff --git a/src/features/vcs/BranchFilterPopup.tsx b/src/features/vcs/BranchFilterPopup.tsx index 87389e2d5..99f3e7376 100644 --- a/src/features/vcs/BranchFilterPopup.tsx +++ b/src/features/vcs/BranchFilterPopup.tsx @@ -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, +): 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>(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({ } renderItem={(r) => - r.type === "ref" ? ( - + r.type === "group" ? ( + + {r.label} + + ) : r.type === "ref" ? ( + + diff --git a/src/features/vcs/GitLog.module.css b/src/features/vcs/GitLog.module.css index 012767f99..edc399238 100644 --- a/src/features/vcs/GitLog.module.css +++ b/src/features/vcs/GitLog.module.css @@ -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); diff --git a/src/features/vcs/__tests__/branchFilterPopup.test.tsx b/src/features/vcs/__tests__/branchFilterPopup.test.tsx index dbfc0139d..31d852d0e 100644 --- a/src/features/vcs/__tests__/branchFilterPopup.test.tsx +++ b/src/features/vcs/__tests__/branchFilterPopup.test.tsx @@ -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 = {}) => ({ 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"); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 6cf26c9de..29262e2d9 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,5 @@ { + "Favorites": "Favorites", "Add": "Add", "Cancel": "Cancel", "Command": "Command", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5c1cf392f..8a542afef 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,5 @@ { + "Favorites": "收藏夹", "Fix ESLint Problems": "修复 ESLint 问题", "No ESLint problems to fix": "没有可修复的 ESLint 问题", "Pin ({count})": "固定({count})",