feat(vcs): Search the Git log branch filter (#129)

The Branch filter in the Git log opens a popup with a search field instead of a plain menu. Branches are fuzzy-matched with highlights and grouped under HEAD, Local and Remote; arrow keys skip the group headers and Enter applies the filter. The list is virtualized so large repositories stay responsive, and Select… still opens the multi-branch / custom revision chooser.

Co-authored-by: zzxzz12345 <[email protected]>
Цей коміт міститься в:
胡翀таzzxzz12345 authored and GitHub committed 2026-10-28 19:32:54 +08:00
1 джерело 3193fd583f
коміт a94b4bff42
5 files changed
+304 -31

No files matched your search

+140
Переглянути файл
@@ -0,0 +1,140 @@
import { 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 styles from "./GitLog.module.css";
type Row =
| { type: "section"; key: string; label: string }
| { type: "ref"; key: string; value: string; label: string; current: 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[] {
const q = query.trim();
const rows: Row[] = [];
const push = (value: string, label: string, current: boolean, out: Row[]) => {
const matches = q ? fuzzyMatch(label, q) : null;
if (q && !matches) return;
out.push({ type: "ref", key: `ref:${value}`, value, label, current, 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));
return rows;
}
const isSection = (r: Row) => r.type === "section";
export function BranchFilterPopup({
anchor,
branches,
selected,
onSelect,
onSelectMany,
onClose,
}: {
anchor: HTMLElement;
branches: readonly Branch[];
selected: readonly string[];
onSelect: (branch: string) => void;
onSelectMany: () => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const rows = useMemo(() => buildRows(branches, query), [branches, query]);
const activeIndex = rows[active] && !isSection(rows[active]!) ? active : firstSelectable(rows, isSection);
const activate = (row: Row | undefined) => {
if (row?.type === "ref") onSelect(row.value);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
activate(rows[activeIndex]);
return;
}
const next = listNavigate(e.key, { items: rows, active: activeIndex, isSection, wrap: true });
if (next !== null) {
e.preventDefault();
setActive(next);
}
};
const height = Math.min(MAX_LIST_HEIGHT, Math.max(ROW_HEIGHT * 3, rows.length * ROW_HEIGHT));
return (
<Popup
open
anchor={anchor}
onClose={onClose}
className={styles.branchFilterPopup}
aria-label={tx("Filter by Branch")}
>
<div className={styles.branchFilterSearch}>
<SearchField
data-autofocus
variant="bare"
value={query}
onChange={(value) => {
setQuery(value);
setActive(0);
}}
onKeyDown={onKeyDown}
placeholder="Search branches"
aria-label="Search branches"
/>
</div>
<div className={styles.branchFilterList} style={{ height }}>
<List
items={rows}
getKey={(r) => r.key}
isSection={isSection}
renderSection={(r) => (
<span className={styles.branchFilterSection}>{r.type === "section" ? r.label : ""}</span>
)}
activeIndex={activeIndex}
onActiveChange={setActive}
onActivate={activate}
focusable={false}
hoverSelects
rowHeight={ROW_HEIGHT}
aria-label="Branches"
className={styles.branchFilterListBody}
empty={
<span className={styles.branchFilterEmpty}>
<T>No matching branches</T>
</span>
}
renderItem={(r) =>
r.type === "ref" ? (
<span className={styles.branchFilterRow}>
<Icon
name={r.current ? "current-branch" : "branch"}
size={14}
className={r.current ? styles.branchFilterCurrent : styles.branchFilterIcon}
/>
<HighlightedText
className={styles.branchFilterLabel}
text={r.label}
indices={r.matches}
tone="accent"
/>
{selected.includes(r.value) && <Icon name="check" size={14} className={styles.branchFilterCheck} />}
</span>
) : null
}
/>
</div>
<button type="button" className={styles.branchFilterMore} onClick={onSelectMany}>
<T>Select…</T>
</button>
</Popup>
);
}
+67
Переглянути файл
@@ -405,3 +405,70 @@
color: var(--link-hover);
text-decoration: underline;
}
.branchFilterPopup {
width: 360px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.branchFilterSearch {
flex: none;
padding: 6px 8px;
border-bottom: 1px solid var(--border-popup);
}
.branchFilterList {
display: flex;
flex-direction: column;
min-height: 0;
padding: 4px 0;
box-sizing: content-box;
}
.branchFilterListBody {
flex: 1;
}
.branchFilterSection {
padding: 0 10px;
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.branchFilterRow {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.branchFilterIcon {
flex: none;
color: var(--fg-subtle);
}
.branchFilterCurrent {
flex: none;
color: var(--accent);
}
.branchFilterLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.branchFilterCheck {
flex: none;
color: var(--accent);
}
.branchFilterEmpty {
color: var(--fg-faint);
}
.branchFilterMore {
flex: none;
display: flex;
align-items: center;
padding: 6px 12px;
border-top: 1px solid var(--border-popup);
text-align: left;
}
.branchFilterMore:hover {
background: var(--bg-tree-hover);
}
+32 -31
Переглянути файл
@@ -1,5 +1,6 @@
/** Git tool window: Log (graph, filters, commit details) and Console tabs. */
import { IssueText } from "./issueLinks";
import { BranchFilterPopup } from "./BranchFilterPopup";
import {
memo,
useCallback,
@@ -325,7 +326,12 @@ function FilterButton({
{!value && <Icon name="chevron-down" size={12} className={styles.filterChevron} />}
</button>
{value && (
<button type="button" className={styles.filterClear} aria-label={tx`Clear ${tx(label)} filter`} onClick={onClear}>
<button
type="button"
className={styles.filterClear}
aria-label={tx`Clear ${tx(label)} filter`}
onClick={onClear}
>
<Icon name="close" size={12} />
</button>
)}
@@ -339,6 +345,7 @@ function FilterBar() {
const branches = useVcs((s) => s.branches);
const root = useVcs((s) => s.root);
const [text, setText] = useState(filters.text);
const [branchAnchor, setBranchAnchor] = useState<HTMLElement | null>(null);
// Keep the field in sync when filters change elsewhere (Show History, reveal).
const [seen, setSeen] = useState(filters);
@@ -353,37 +360,18 @@ function FilterBar() {
return () => clearTimeout(t);
}, [text, filters.text]);
const only = (list: readonly string[], v: string) => list.length === 1 && list[0] === v;
const branchNames = () => branches.filter((b) => !b.name.endsWith("/HEAD")).map((b) => b.name);
const openBranch = (e: ReactMouseEvent<HTMLElement>) =>
menuBelow(e, [
{
label: "HEAD (Current Branch)",
checked: only(filters.branches, "HEAD"),
run: () => setLogFilters({ branches: ["HEAD"] }),
},
{ kind: "separator" },
...branchNames().map((name) => ({
label: name,
icon: "branch",
checked: filters.branches.includes(name),
run: () => setLogFilters({ branches: [name] }),
})),
{ kind: "separator" },
{
label: "Select…",
run: async () => {
const r = await chooseMany({
title: "Filter by Branch",
options: ["HEAD", ...branchNames()],
initial: filters.branches,
customLabel: "Other revisions (comma-separated):",
});
if (r) setLogFilters({ branches: r });
},
},
]);
const selectBranches = async () => {
setBranchAnchor(null);
const r = await chooseMany({
title: "Filter by Branch",
options: ["HEAD", ...branchNames()],
initial: filters.branches,
customLabel: "Other revisions (comma-separated):",
});
if (r) setLogFilters({ branches: r });
};
const openUser = (e: ReactMouseEvent<HTMLElement>) => {
const authors = [...new Set(commits.map((c) => c.author))].sort((a, b) => a.localeCompare(b)).slice(0, 30);
@@ -470,9 +458,22 @@ function FilterBar() {
<FilterButton
label="Branch"
value={filters.branches.join(", ") || null}
onOpen={openBranch}
onOpen={(e) => setBranchAnchor(e.currentTarget)}
onClear={() => setLogFilters({ branches: [] })}
/>
{branchAnchor && (
<BranchFilterPopup
anchor={branchAnchor}
branches={branches}
selected={filters.branches}
onClose={() => setBranchAnchor(null)}
onSelect={(branch) => {
setLogFilters({ branches: [branch] });
setBranchAnchor(null);
}}
onSelectMany={() => void selectBranches()}
/>
)}
<FilterButton
label="User"
value={filters.authors.join(", ") || null}
+64
Переглянути файл
@@ -0,0 +1,64 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { Branch } from "@/bindings";
import { BranchFilterPopup } from "../BranchFilterPopup";
const branch = (name: string, extra: Partial<Branch> = {}) =>
({ name, remote: false, current: false, ...extra }) as Branch;
const branches = [
branch("main", { current: true }),
branch("feature/demo"),
branch("origin/HEAD", { remote: true }),
branch("origin/develop", { remote: true }),
];
function renderPopup(onSelect = vi.fn()) {
render(
<BranchFilterPopup
anchor={document.body}
branches={branches}
selected={["main"]}
onSelect={onSelect}
onSelectMany={vi.fn()}
onClose={vi.fn()}
/>,
);
return { input: screen.getByRole("textbox", { name: "Search branches" }), onSelect };
}
describe("branch filter popup", () => {
it("groups HEAD, local and remote branches and leaves out the remote HEAD", () => {
renderPopup();
expect(screen.getByText("Local")).toBeTruthy();
expect(screen.getByText("Remote")).toBeTruthy();
expect(screen.getByText("origin/develop")).toBeTruthy();
expect(screen.queryByText("origin/HEAD")).toBeNull();
});
it("searches case-insensitively and hides groups without a match", () => {
const { input, onSelect } = renderPopup();
fireEvent.change(input, { target: { value: "DEVELOP" } });
expect(screen.getByText("Remote")).toBeTruthy();
expect(screen.queryByText("Local")).toBeNull();
expect(screen.queryByText("main")).toBeNull();
fireEvent.keyDown(input, { key: "Enter" });
expect(onSelect).toHaveBeenCalledWith("origin/develop");
});
it("moves over section headers with the arrow keys", () => {
const { input, onSelect } = renderPopup();
// HEAD → (Local header skipped) → main
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSelect).toHaveBeenLastCalledWith("main");
});
it("highlights fuzzy matches and shows an empty state", () => {
const { input } = renderPopup();
fireEvent.change(input, { target: { value: "fdm" } });
expect(document.querySelectorAll("mark")).toHaveLength(3);
fireEvent.change(input, { target: { value: "missing" } });
expect(screen.getByText("No matching branches")).toBeTruthy();
});
});
+1
Переглянути файл
@@ -1748,6 +1748,7 @@
"Search Structurally…": "结构化搜索…",
"Search Text Everywhere": "随处搜索文本",
"Search actions": "搜索操作",
"Search branches": "搜索分支",
"Search branches and actions": "搜索分支和操作",
"Search configurations": "搜索配置",
"Search for references": "搜索引用",