From 7e7da950fcd3569ed22b7ba5ab918e0ab67f4e16 Mon Sep 17 00:00:00 2001 From: Shohruh Ismoilov Date: Sat, 7 Nov 2026 07:37:24 +0500 Subject: [PATCH] fix(vcs): show branch actions in the Git Log commit menu (#1852) Right-clicking a commit with branch labels offered commit actions only. The menu now has a Branch 'name' submenu with the existing branch actions for each branch at that commit other than the current one, grouped under Branches when there are more than two, and keeps Rebase onto Selected Commit for commits without branches. The actions run against the commit's repository in a multi-repository project. Co-authored-by: shohruh-genius <7658451+shohruh-genius@users.noreply.github.com> --- e2e/gitLogBranchMenu.spec.ts | 90 +++++++++++++++++++++++++++++++ src/features/vcs/GitLog.tsx | 28 +++++++--- src/features/vcs/branchMenu.ts | 10 ++-- src/features/vcs/logStore.ts | 12 ++++- src/features/vcs/multiRoot.ts | 1 + src/platform/locales/text/en.json | 3 +- src/platform/locales/text/zh.json | 1 + 7 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 e2e/gitLogBranchMenu.spec.ts diff --git a/e2e/gitLogBranchMenu.spec.ts b/e2e/gitLogBranchMenu.spec.ts new file mode 100644 index 000000000..a62e64a3f --- /dev/null +++ b/e2e/gitLogBranchMenu.spec.ts @@ -0,0 +1,90 @@ +import { expect, ipcCalls, stubDesktop, test } from "./fixtures"; + +test("opens a commit's branch menu and acts in its linked repository", async ({ workbench: page }) => { + await stubDesktop(page); + await page.evaluate(async () => { + const primary = "/dev/acme-api"; + const linked = "/dev/acme-web"; + const tip = "1".repeat(40); + const h = window.__ipc.handlers; + h.vcs_roots = () => + [ + { root: primary, head: "main" }, + { root: linked, head: "release" }, + ].map((repo) => ({ ...repo, operation: null, headCommit: tip })); + h.vcs_branch_info = ({ repo }) => ({ + head: repo === linked ? "release" : "main", + headCommit: tip, + upstream: null, + ahead: 0, + behind: 0, + operation: null, + }); + h.vcs_branches = ({ repo }) => + [repo === linked ? "origin/dev" : "origin/other"].map((name) => ({ + name, + commit: tip, + remote: true, + current: false, + upstream: null, + ahead: 0, + behind: 0, + lastCommitMs: 0, + })); + h.vcs_log = ({ repo }) => { + const summary = repo === linked ? "Linked repository commit" : "Primary repository commit"; + return [ + { + id: tip, + shortId: tip.slice(0, 8), + parents: [], + refs: [repo === linked ? "origin/dev" : "origin/other"], + author: "A", + email: "a@example.com", + timeMs: 0, + summary, + message: summary, + }, + ]; + }; + h.vcs_log_refs = () => ({}); + h.vcs_log_details_batch = ({ revisions }) => + (revisions as string[]).map(() => ({ + message: "Linked repository commit", + containingBranches: [], + signature: "N", + signer: "", + key: "", + fingerprint: "", + })); + h.vcs_commit_files = () => []; + h.vcs_config_get = () => null; + h.vcs_tags = () => []; + h.vcs_status = () => []; + h.vcs_create_branch = () => null; + const vcsModule = "/src/features/vcs/store.ts"; + const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule); + useVcs.setState({ available: true, root: primary, repo: primary }); + await refreshNow(); + const contextModule = "/src/platform/context.ts"; + const { setContext } = await import(/* @vite-ignore */ contextModule); + setContext("vcsAvailable", true); + const actionsModule = "/src/platform/actions.ts"; + const { executeAction } = await import(/* @vite-ignore */ actionsModule); + await executeAction("vcs.showLog"); + }); + + await page.getByRole("option", { name: /Linked repository commit/ }).click({ button: "right" }); + const menu = page.getByRole("menu", { name: "Context menu" }); + await expect(menu.getByRole("menuitem", { name: "Branch 'origin/other'" })).toHaveCount(0); + await menu.getByRole("menuitem", { name: "Branch 'origin/dev'" }).hover(); + await expect(page.getByRole("menuitem", { name: "Pull into 'release' Using Rebase" })).toBeVisible(); + const newBranch = page.getByRole("menuitem", { name: "New Branch from 'origin/dev'…" }); + await newBranch.click(); + const dialog = page.getByRole("dialog", { name: "Create New Branch from origin/dev" }); + await dialog.getByRole("textbox", { name: "New branch name:" }).fill("test-branch"); + await dialog.getByRole("button", { name: "Create", exact: true }).click(); + await expect + .poll(() => ipcCalls(page, "vcs_create_branch")) + .toMatchObject([{ repo: "/dev/acme-web", name: "test-branch", start: "origin/dev", checkout: true }]); +}); diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 0a746dd69..06ae38ff3 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -1121,7 +1121,9 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] { { label: "Browse Repository at Revision", run: () => browseRepositoryAt(c) }, ]; const many = selection.length > 1 && selection.some((x) => commitKey(x) === commitKey(c)); - const head = (commitRepo(c) ? useVcs.getState().heads[commitRepo(c)!]?.head : useVcs.getState().branch?.head) ?? null; + const repo = commitRepo(c); + const vcs = useVcs.getState(); + const head = (repo ? vcs.heads[repo]?.head : vcs.branch?.head) ?? null; const { outgoing } = useGitLog.getState(); const pushed = (x: Commit) => commitRepo(x) === undefined ? !!outgoing && !outgoing.has(x.id) : isPushed(commitKey(x)); @@ -1148,6 +1150,22 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] { ((commitRepo(c) === undefined ? !!outgoing : useGitLog.getState().tracked.has(commitRepo(c) ?? "")) ? !!outgoing?.has(commitKey(c)) : true); + const branches = ((c as RepoCommit).refBranches ?? []).filter((branch) => !branch.current); + const branchActions: MenuItem[] = branches.map((branch) => { + const menu = branchMenu(branch, head); + return { + label: tx`Branch '${branch.name}'`, + submenu: repo ? inRepo(repo, menu) : menu, + }; + }); + if (!branchActions.length) + branchActions.push({ + kind: "action", + action: "vcs.log.rebaseOntoCommit", + arg: c, + label: tx`Rebase '${head ?? "HEAD"}' onto Selected Commit`, + disabled: !head, + }); return [ { kind: "action", action: "vcs.log.copyRevision", arg: c }, { @@ -1173,13 +1191,7 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] { { kind: "action", action: "vcs.log.interactiveRebase", arg: c, disabled: pushedCommit }, { kind: "action", action: "vcs.log.pushUpToHere", arg: c, disabled: !canPushHere }, { kind: "separator" }, - { - kind: "action", - action: "vcs.log.rebaseOntoCommit", - arg: c, - label: tx`Rebase '${head ?? "HEAD"}' onto Selected Commit`, - disabled: !head, - }, + ...(branchActions.length > 2 ? [{ label: "Branches", submenu: branchActions }] : branchActions), { kind: "action", action: "vcs.log.newBranch", arg: c }, { kind: "action", action: "vcs.log.newTag", arg: c }, { kind: "separator" }, diff --git a/src/features/vcs/branchMenu.ts b/src/features/vcs/branchMenu.ts index 2facfcde1..1805f6440 100644 --- a/src/features/vcs/branchMenu.ts +++ b/src/features/vcs/branchMenu.ts @@ -31,7 +31,7 @@ import { showCompareBranches } from "./CompareBranches"; import { showDiffWithWorkingTree } from "./operations"; import type { CommonBranch } from "./repoBranches"; import { runRemote, saveAll } from "./ops"; -import { focusRepo, useVcs } from "./store"; +import { currentRepo, focusRepo, useVcs } from "./store"; import { tx } from "@/platform/i18n"; /** `git pull ` into the current branch (`origin/feat/x` → origin, feat/x). */ @@ -173,6 +173,10 @@ export function commonBranchMenu(b: CommonBranch, roots: readonly string[], done /** `items` acting on repository `root`: it is selected (with its branches loaded) before each entry runs. */ export function inRepo(root: string, items: MenuItem[]): MenuItem[] { + const runInRepo = (run: () => unknown) => () => + void focusRepo(root).then(() => { + if (currentRepo() === root) return run(); + }); return items.map((item): MenuItem => { if (item.kind === "separator" || item.kind === "header") return item; if (item.kind === "action") @@ -180,12 +184,12 @@ export function inRepo(root: string, items: MenuItem[]): MenuItem[] { label: item.label ?? item.action, icon: item.icon, disabled: item.disabled, - run: () => void focusRepo(root).then(() => executeAction(item.action, item.arg)), + run: runInRepo(() => executeAction(item.action, item.arg)), }; const run = item.run; return { ...item, - run: run && (() => void focusRepo(root).then(run)), + run: run && runInRepo(run), submenu: item.submenu && inRepo(root, item.submenu), }; }); diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index de7015e2e..071d26363 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -973,13 +973,23 @@ function loadPage(replace: boolean, id: string, before = false): Promise { } else page = await fetchPage(null); } else page = await fetchPage(null); } + const repoBranches = repo ? (refs[repo] ?? []) : []; + const remoteNames = repo ? repoBranches.filter((b) => b.remote).map((b) => b.name) : undefined; + const branchesByTip = new Map(); + for (const branch of repoBranches) { + if (!branch.commit) continue; + const atTip = branchesByTip.get(branch.commit); + if (atTip) atTip.push(branch); + else branchesByTip.set(branch.commit, [branch]); + } return page.map((c) => ({ ...c, repo, user, userName, readOnly: tab?.readOnly === true, - remoteNames: repo ? refs[repo]?.filter((b) => b.remote).map((b) => b.name) : undefined, + remoteNames, + refBranches: branchesByTip.get(c.id)?.filter((branch) => c.refs.includes(branch.name)), })); }), ); diff --git a/src/features/vcs/multiRoot.ts b/src/features/vcs/multiRoot.ts index 6ce6faae3..1753c5d58 100644 --- a/src/features/vcs/multiRoot.ts +++ b/src/features/vcs/multiRoot.ts @@ -8,6 +8,7 @@ export type RepoCommit = Commit & { user?: string | null; userName?: string | null; remoteNames?: string[]; + refBranches?: Branch[]; readOnly?: boolean; }; export const commitRepo = (c: Commit | undefined): string | null | undefined => (c as RepoCommit | undefined)?.repo; diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 3963bbfe7..f88c95baf 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -57,5 +57,6 @@ "Recommended": "Recommended", "No preview": "No preview", "Running in the background": "Running in the background", - "Couldn't stop the background task": "Couldn't stop the background task" + "Couldn't stop the background task": "Couldn't stop the background task", + "Branch '{name}'": "Branch '{name}'" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d0097a991..967e047c2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4269,6 +4269,7 @@ "Branch {name}": "分支 {name}", "Tag {name}": "标签 {name}", "Branch {branch} of {repository}": "{repository} 的分支 {branch}", + "Branch '{name}'": "分支 '{name}'", "Branches of {root}": "{root} 的分支", "Include {name} in commit": "在提交中包含 {name}", "Compare {current} with {branch}": "比较 {current} 与 {branch}",