дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
3193fd583f
коміт
a94b4bff42
5 files changed
+304
-31
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1748,6 +1748,7 @@
|
||||
"Search Structurally…": "结构化搜索…",
|
||||
"Search Text Everywhere": "随处搜索文本",
|
||||
"Search actions": "搜索操作",
|
||||
"Search branches": "搜索分支",
|
||||
"Search branches and actions": "搜索分支和操作",
|
||||
"Search configurations": "搜索配置",
|
||||
"Search for references": "搜索引用",
|
||||
|
||||
Reference in new issue
Заблокувати користувача