mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
{
|
||||
"Favorites": "Favorites",
|
||||
"Add": "Add",
|
||||
"Cancel": "Cancel",
|
||||
"Command": "Command",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Favorites": "收藏夹",
|
||||
"Fix ESLint Problems": "修复 ESLint 问题",
|
||||
"No ESLint problems to fix": "没有可修复的 ESLint 问题",
|
||||
"Pin ({count})": "固定({count})",
|
||||
|
||||
Reference in New Issue
Block a user