feat(vcs): Double click switches the log and Cmd/Ctrl+C copies branches in Git Log (#101)

In Git Log's Branches pane a single click filtered the log and a double click checked the branch out. Now a click only selects; double click or Enter shows the log of that ref (also the current branch and HEAD), and Enter on a multi-selection filters by all selected branches in tree order. Checkout stays in the context menu. The pane keeps its own selection and follows filter changes made elsewhere. Cmd/Ctrl+C copies the selected branches as full refs, one per line (HEAD as `HEAD`), leaving copy in the search field and elsewhere untouched.

Co-authored-by: itning <19759891+itning@users.noreply.github.com>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
itning
2026-10-27 21:02:10 +08:00
committed by GitHub
co-authored by itning
parent c3b64304ac
commit 6de6b33449
2 changed files with 188 additions and 12 deletions
+47 -12
View File
@@ -30,7 +30,9 @@ import {
showContextMenu,
type MenuItem,
T,
copyText,
} from "@/ui";
import { hasPrimaryModifier } from "@/platform/keymap";
import { commitFileDiffData, openDiff, revisionChangeDiffData, withChain } from "./diffInput";
import { collapseLinear, computeGraph, hideLongEdges, laneColor, reachableFrom, type GraphRow } from "./graph";
import {
@@ -54,7 +56,7 @@ import { refLabels, useLogOptions, type LogOptions } from "./logOptions";
import { KIND_COLOR, baseName, formatTime, relTo, type EffectiveKind } from "./model";
import { clearConsole, useVcsConsole } from "./ops";
import { currentRepo, loadBranches, useVcs } from "./store";
import { checkoutBranch, loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps";
import { loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps";
import { branchMenu } from "./branchMenu";
import { branchFolderKeys, branchGroupKeys, buildBranchTree, filterBranchTree, type BranchTreeNode } from "./branches";
import { chooseDateRange, chooseMany, promptText } from "./dialogs";
@@ -167,7 +169,7 @@ function branchDetail(n: BranchTreeNode): string | undefined {
return [b.current ? "current" : "", sync].filter(Boolean).join(" ") || undefined;
}
function BranchesPanel() {
export function BranchesPanel() {
const branches = useVcs((s) => s.branches);
const root = useVcs((s) => s.root);
const branchesLoaded = useVcs((s) => s.branchesLoaded);
@@ -180,12 +182,43 @@ function BranchesPanel() {
const roots = useMemo(() => filterBranchTree(allRoots, query), [allRoots, query]);
// While searching every match is shown expanded.
const searchExpanded = useMemo(() => (query.trim() ? new Set(branchGroupKeys(roots)) : undefined), [roots, query]);
const selected = useMemo(
() => new Set(filterBranches.map((b) => (b === "HEAD" ? "head" : `b:${b}`))),
[filterBranches],
);
// Highlighted rows: a click only selects (IDEA), and only a double click or
// Enter switches the log, so the selection is the pane's own state. It still
// follows the filter when something else changes it (Show Log, the filter bar).
const filterKeys = useMemo(() => filterBranches.map((b) => (b === "HEAD" ? "head" : `b:${b}`)), [filterBranches]);
const filterKey = filterKeys.join("\n");
const [shownKey, setShownKey] = useState(filterKey);
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set(filterKeys));
if (shownKey !== filterKey) {
// The filter changed elsewhere (Show Log, the filter bar): highlight it.
setShownKey(filterKey);
setSelected(new Set(filterKeys));
}
// Branches in tree order, for Copy: full refs, so the folder path comes along
// (`feat/x`, `origin/main`).
const refs = useMemo(() => {
const out: { key: string; ref: string }[] = [];
const walk = (nodes: readonly BranchTreeNode[]) => {
for (const n of nodes) {
if (n.ref) out.push({ key: n.key, ref: n.ref });
walk(n.children ?? []);
}
};
walk(allRoots);
return out;
}, [allRoots]);
const onKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>) => {
if ((e.key !== "c" && e.key !== "C") || !hasPrimaryModifier(e) || e.altKey || e.shiftKey) return;
// The branch search keeps the browser's own copy.
if ((e.target as HTMLElement).closest("input, textarea")) return;
const names = refs.filter((r) => selected.has(r.key)).map((r) => r.ref);
if (!names.length) return;
e.preventDefault();
e.stopPropagation();
void copyText(names.join("\n"));
};
return (
<div className={styles.branches}>
<div className={styles.branches} onKeyDownCapture={onKeyDownCapture}>
<div className={styles.branchesBar} role="toolbar" aria-label="Branches">
<SearchField
className={styles.branchSearch}
@@ -225,12 +258,14 @@ function BranchesPanel() {
}
getDetail={branchDetail}
selected={selected}
onSelectionChange={(sel) => {
// Several selected branches filter the log by all of them.
const refs = [...sel].flatMap((k) => (k === "head" ? ["HEAD"] : k.startsWith("b:") ? [k.slice(2)] : []));
if (refs.length) setLogFilters({ branches: refs });
onSelectionChange={(sel) => setSelected(sel)}
onActivate={(n) => {
// IDEA: double click (or Enter) switches the log to that ref, or to every
// selected one when it is part of a multi-selection; checking out stays in the context menu.
if (!n.ref) return;
const sel = selected.has(n.key) ? refs.filter((r) => selected.has(r.key)).map((r) => r.ref) : [n.ref];
setLogFilters({ branches: sel });
}}
onActivate={(n) => n.branch && !n.branch.current && void checkoutBranch(n.branch.name)}
onContextMenu={(n, _s, e) => {
const items = n?.branch ? branchMenu(n.branch, current) : [];
if (!items.length) return;
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Branch, BranchInfo } from "@/bindings";
import { isMacHost } from "@/platform/keymap";
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
vcsLog: () => Promise.resolve({ status: "ok", data: [] }),
vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }),
},
};
});
import { BranchesPanel } from "../GitLog";
import { emptyFilters, useGitLog } from "../logStore";
import { useVcs } from "../store";
const b = (name: string, opts: Partial<Branch> = {}): Branch => ({
name,
remote: false,
current: false,
upstream: null,
ahead: 0,
behind: 0,
lastCommitMs: 0,
commit: "",
...opts,
});
const branches = [b("main", { current: true, commit: "1" }), b("feature", { commit: "2" })];
const head: BranchInfo = {
head: "main",
headCommit: "1",
upstream: null,
ahead: 0,
behind: 0,
operation: null,
};
const writeText = vi.fn().mockResolvedValue(undefined);
/** The copy modifier on this host: ⌘ on macOS, Ctrl elsewhere. */
const cmd = () => (isMacHost() ? { metaKey: true } : { ctrlKey: true });
beforeEach(() => {
writeText.mockClear();
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
useVcs.setState({ root: "/p", repo: "/p", branches, branchesLoaded: true, branch: head });
useGitLog.setState({ filters: emptyFilters() });
});
describe("Git Log branches pane", () => {
it("one click only selects the branch", () => {
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("feature"));
expect(useGitLog.getState().filters.branches).toEqual([]);
expect(screen.getByText("feature").closest("[data-row]")).toHaveAttribute("aria-selected", "true");
});
it("double click switches the log to that branch", () => {
render(<BranchesPanel />);
fireEvent.doubleClick(screen.getByText("feature"));
expect(useGitLog.getState().filters.branches).toEqual(["feature"]);
});
it("double click on the current branch switches to its log too", () => {
render(<BranchesPanel />);
fireEvent.doubleClick(screen.getByText("main"));
expect(useGitLog.getState().filters.branches).toEqual(["main"]);
});
it("double click on HEAD shows HEAD's log", () => {
render(<BranchesPanel />);
fireEvent.doubleClick(screen.getByText("HEAD (Current Branch)"));
expect(useGitLog.getState().filters.branches).toEqual(["HEAD"]);
});
it("Enter switches the log to every selected branch, in tree order", () => {
useVcs.setState({ branches: [b("main", { current: true }), b("beta"), b("alpha")] });
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("beta"));
fireEvent.mouseDown(screen.getByText("alpha"), cmd());
expect(useGitLog.getState().filters.branches).toEqual([]);
fireEvent.keyDown(screen.getByRole("tree", { name: "Branches" }), { key: "Enter" });
expect(useGitLog.getState().filters.branches).toEqual(["alpha", "beta"]);
});
it("follows the log filter when it changes elsewhere", () => {
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("main"));
act(() => useGitLog.setState({ filters: { ...emptyFilters(), branches: ["feature"] } }));
expect(screen.getByText("feature").closest("[data-row]")).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("main").closest("[data-row]")).toHaveAttribute("aria-selected", "false");
});
it("copies the selected branch name", async () => {
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("feature"));
fireEvent.keyDown(screen.getByText("feature"), { key: "c", ...cmd() });
await waitFor(() => expect(writeText).toHaveBeenCalledWith("feature"));
});
it("copies HEAD as HEAD", async () => {
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("HEAD (Current Branch)"));
fireEvent.keyDown(screen.getByText("HEAD (Current Branch)"), { key: "c", ...cmd() });
await waitFor(() => expect(writeText).toHaveBeenCalledWith("HEAD"));
});
it("copies the folder path along with the branch name", async () => {
useVcs.setState({
branches: [b("feat/idea-parity", { current: true, commit: "1" })],
branch: { ...head, head: "feat/idea-parity" },
});
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("idea-parity"));
fireEvent.keyDown(screen.getByText("idea-parity"), { key: "c", ...cmd() });
await waitFor(() => expect(writeText).toHaveBeenCalledWith("feat/idea-parity"));
});
it("copies every selected branch, one per line, in tree order", async () => {
useVcs.setState({ branches: [b("main", { current: true }), b("beta"), b("alpha")] });
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("beta"), cmd());
fireEvent.mouseDown(screen.getByText("alpha"), cmd());
fireEvent.keyDown(screen.getByText("alpha"), { key: "c", ...cmd() });
await waitFor(() => expect(writeText).toHaveBeenCalledWith("alpha\nbeta"));
});
it("leaves Cmd/Ctrl+C in the branch search field alone", async () => {
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("feature"));
fireEvent.keyDown(screen.getByLabelText("Filter branches"), { key: "c", ...cmd() });
await Promise.resolve();
expect(writeText).not.toHaveBeenCalled();
});
});