mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
+47
-12
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user