From a94b4bff42bd4596da4c88802af9c15aaf699eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=BF=80?= <1020327414@qq.com> Date: Wed, 28 Oct 2026 19:32:54 +0800 Subject: [PATCH] feat(vcs): Search the Git log branch filter (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 <8338183+zzxzz12345@users.noreply.github.com> --- src/features/vcs/BranchFilterPopup.tsx | 140 ++++++++++++++++++ src/features/vcs/GitLog.module.css | 67 +++++++++ src/features/vcs/GitLog.tsx | 63 ++++---- .../vcs/__tests__/branchFilterPopup.test.tsx | 64 ++++++++ src/platform/locales/text/zh.json | 1 + 5 files changed, 304 insertions(+), 31 deletions(-) create mode 100644 src/features/vcs/BranchFilterPopup.tsx create mode 100644 src/features/vcs/__tests__/branchFilterPopup.test.tsx diff --git a/src/features/vcs/BranchFilterPopup.tsx b/src/features/vcs/BranchFilterPopup.tsx new file mode 100644 index 000000000..87389e2d5 --- /dev/null +++ b/src/features/vcs/BranchFilterPopup.tsx @@ -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 ( + +
+ { + setQuery(value); + setActive(0); + }} + onKeyDown={onKeyDown} + placeholder="Search branches" + aria-label="Search branches" + /> +
+
+ r.key} + isSection={isSection} + renderSection={(r) => ( + {r.type === "section" ? r.label : ""} + )} + activeIndex={activeIndex} + onActiveChange={setActive} + onActivate={activate} + focusable={false} + hoverSelects + rowHeight={ROW_HEIGHT} + aria-label="Branches" + className={styles.branchFilterListBody} + empty={ + + No matching branches + + } + renderItem={(r) => + r.type === "ref" ? ( + + + + {selected.includes(r.value) && } + + ) : null + } + /> +
+ +
+ ); +} diff --git a/src/features/vcs/GitLog.module.css b/src/features/vcs/GitLog.module.css index c3566418d..a1d11a165 100644 --- a/src/features/vcs/GitLog.module.css +++ b/src/features/vcs/GitLog.module.css @@ -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); +} diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 6aff5e2c5..6e364171b 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -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 && } {value && ( - )} @@ -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(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) => - 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) => { const authors = [...new Set(commits.map((c) => c.author))].sort((a, b) => a.localeCompare(b)).slice(0, 30); @@ -470,9 +458,22 @@ function FilterBar() { setBranchAnchor(e.currentTarget)} onClear={() => setLogFilters({ branches: [] })} /> + {branchAnchor && ( + setBranchAnchor(null)} + onSelect={(branch) => { + setLogFilters({ branches: [branch] }); + setBranchAnchor(null); + }} + onSelectMany={() => void selectBranches()} + /> + )} = {}) => + ({ 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( + , + ); + 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(); + }); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5286b6a5b..06a0e36df 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1748,6 +1748,7 @@ "Search Structurally…": "结构化搜索…", "Search Text Everywhere": "随处搜索文本", "Search actions": "搜索操作", + "Search branches": "搜索分支", "Search branches and actions": "搜索分支和操作", "Search configurations": "搜索配置", "Search for references": "搜索引用",