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}",