From 6de6b33449c7f4d5ddf669ffb4c6d0d25e2607cd Mon Sep 17 00:00:00 2001 From: itning Date: Tue, 27 Oct 2026 21:02:10 +0800 Subject: [PATCH] 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 --- src/features/vcs/GitLog.tsx | 59 ++++++-- .../vcs/__tests__/logBranches.test.tsx | 141 ++++++++++++++++++ 2 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 src/features/vcs/__tests__/logBranches.test.tsx diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index a5472dff..87a485c7 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -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>(() => 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) => { + 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 ( -
+
{ - // 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; diff --git a/src/features/vcs/__tests__/logBranches.test.tsx b/src/features/vcs/__tests__/logBranches.test.tsx new file mode 100644 index 00000000..676190c4 --- /dev/null +++ b/src/features/vcs/__tests__/logBranches.test.tsx @@ -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(); + 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 => ({ + 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(); + 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(); + 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(); + fireEvent.doubleClick(screen.getByText("main")); + expect(useGitLog.getState().filters.branches).toEqual(["main"]); + }); + + it("double click on HEAD shows HEAD's log", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + fireEvent.mouseDown(screen.getByText("feature")); + fireEvent.keyDown(screen.getByLabelText("Filter branches"), { key: "c", ...cmd() }); + await Promise.resolve(); + expect(writeText).not.toHaveBeenCalled(); + }); +});