From 278936dd7dc6646b23c3dedb83fa08f56b9ec7ba Mon Sep 17 00:00:00 2001 From: Muu Date: Sun, 8 Nov 2026 20:45:49 +0800 Subject: [PATCH] fix(vcs): delete every selected branch, not only the right-clicked one (#2108) In the Git Log Branches pane, Delete removed only the right-clicked branch and ignored the multi-selection: the menu was built from the clicked node alone and the action read only the first selected ref. Delete now acts on the whole selection when the right-clicked branch is part of it. Current branches are skipped, each repository is focused for its branches and the original focus is restored afterwards, and all remote branches share one confirmation. Siblings covered: the toolbar button, and the View Commits and Delete Tracked Branch notification actions, which now stay on the repository the branch was deleted from. Co-authored-by: muutot <92625353+muutot@users.noreply.github.com> --- src/features/vcs/GitLog.tsx | 27 ++- .../vcs/__tests__/deleteBranches.test.ts | 171 ++++++++++++++++++ src/features/vcs/__tests__/logPanes.test.tsx | 41 ++++- src/features/vcs/branchMenu.ts | 13 +- src/features/vcs/branchOps.ts | 53 +++++- src/features/vcs/logPaneActions.ts | 19 +- src/platform/locales/text/en.json | 2 + src/platform/locales/text/zh.json | 2 + 8 files changed, 303 insertions(+), 25 deletions(-) create mode 100644 src/features/vcs/__tests__/deleteBranches.test.ts diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 0b92336e5..b1d5e846c 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -86,7 +86,7 @@ import { logOptions, refLabels, useLogOptions, type LogOptions } from "./logOpti import { KIND_COLOR, baseName, dirName, fileCount, formatTime, relTo, type EffectiveKind } from "./model"; import { clearConsole, useVcsConsole } from "./ops"; import { currentRepo, currentRepoRoot, loadBranches, repoName, useVcs } from "./store"; -import { loadFavoriteBranches, useFavoriteBranches } from "./branchOps"; +import { canDeleteBranch, loadFavoriteBranches, useFavoriteBranches } from "./branchOps"; import { branchMenu, inRepo, tagMenu } from "./branchMenu"; import { branchFolderKeys, @@ -110,7 +110,7 @@ import { withLineStats, type LogChange, } from "./logChanges"; -import { canDeleteBranch, canUpdateBranch, useBranchesPane, type BranchTarget } from "./logPaneActions"; +import { canUpdateBranch, useBranchesPane, type BranchTarget } from "./logPaneActions"; import styles from "./GitLog.module.css"; import { tx } from "@/platform/i18n"; import { useWorkflowSettings } from "@/platform/workflowSettings"; @@ -496,8 +496,8 @@ export function BranchesPanel() { size="small" title="Delete Branch" action="vcs.log.branches.delete" - actionArg={target} - disabled={!canDeleteBranch(target ?? null)} + actionArg={targets} + disabled={!targets.some((t) => canDeleteBranch(t))} /> r.repo === n.repo).map((r) => r.ref) : [n.ref]; setLogFilters({ branches: sel, repositories: n.repo ? [n.repo] : [] }); }} - onContextMenu={(n, _s, e) => { + onContextMenu={(n, _sel, e) => { if (readOnly) { if (!n?.ref) return; e.preventDefault(); @@ -584,14 +584,27 @@ export function BranchesPanel() { ]); return; } + // The menu acts on the whole selection when the right-clicked branch is part of it. + const picked: BranchTarget[] = + n && selected.has(n.key) ? targets : n?.ref ? [{ ref: n.ref, branch: n.branch, repo: n.repo }] : []; const menu = n?.branch - ? branchMenu(n.branch, n.repo ? (useVcs.getState().heads[n.repo]?.head ?? null) : current) + ? branchMenu( + n.branch, + n.repo ? (useVcs.getState().heads[n.repo]?.head ?? null) : current, + () => {}, + picked, + ) : n?.kind === "tag" ? tagMenu(n.ref!) : n?.kind === "remote" || (n?.kind === "group" && n.key.endsWith("g:remote")) ? REMOTES_MENU : []; - const items = n?.repo ? inRepo(n.repo, menu) : menu; + // Delete scopes each selected target itself and must preserve the original focus. + const items = n?.repo + ? menu.flatMap((item) => + n.branch && "label" in item && item.label === "Delete" ? [item] : inRepo(n.repo!, [item]), + ) + : menu; if (!items.length) return; e.preventDefault(); showContextMenu(e, items); diff --git a/src/features/vcs/__tests__/deleteBranches.test.ts b/src/features/vcs/__tests__/deleteBranches.test.ts new file mode 100644 index 000000000..4a410db9b --- /dev/null +++ b/src/features/vcs/__tests__/deleteBranches.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type { Branch } from "@/bindings"; + +const mocks = vi.hoisted(() => ({ + confirm: vi.fn(), + local: vi.fn(), + remote: vi.fn(), + focus: vi.fn(), + notify: vi.fn(), + range: vi.fn(), +})); +vi.mock("../dialogs", async (original) => ({ + ...(await original()), + confirmDialog: mocks.confirm, +})); +vi.mock("../ops", async (original) => ({ + ...(await original()), + runOp: mocks.local, + runRemote: mocks.remote, +})); +vi.mock("../store", async (original) => ({ + ...(await original()), + focusRepo: mocks.focus, +})); + +vi.mock("@/workbench/contributions", async (original) => ({ + ...(await original()), + notifications: () => ({ notify: mocks.notify }), +})); + +vi.mock("../CompareBranches", async (original) => ({ + ...(await original()), + showCommitRange: mocks.range, +})); + +import { canDeleteBranch, deleteBranches } from "../branchOps"; +import { branchMenu } from "../branchMenu"; +import { registerLogPaneActions, useBranchesPane } from "../logPaneActions"; +import { getAction } from "@/platform/actions"; +import { useVcs } from "../store"; + +const branch = (name: string, opts: Partial = {}): Branch => ({ + name, + remote: false, + current: false, + upstream: null, + ahead: 0, + behind: 0, + lastCommitMs: null, + ...opts, +}); +const target = (name: string, repo = "/p/a", opts: Partial = {}) => ({ + ref: name, + repo, + branch: branch(name, opts), +}); + +beforeEach(() => { + vi.resetAllMocks(); + useVcs.setState({ root: "/p", repo: "/p/a", branches: [], branchesLoaded: true }); + useBranchesPane.setState({ selected: [] }); + mocks.confirm.mockResolvedValue(true); + mocks.focus.mockImplementation(async (repo: string) => { + useVcs.setState({ repo }); + }); +}); + +it("restores the original repository after deleting across repositories", async () => { + const repos: (string | null)[] = []; + mocks.local.mockImplementation(async () => { + repos.push(useVcs.getState().repo); + }); + await deleteBranches([target("one", "/p/b"), target("two", "/p/a"), target("three", "/p/b")]); + expect(repos).toEqual(["/p/b", "/p/a", "/p/b"]); + expect(useVcs.getState().repo).toBe("/p/a"); +}); + +it("restores focus when deletion throws", async () => { + mocks.local.mockRejectedValue(new Error("failed")); + await expect(deleteBranches([target("one", "/p/b")])).rejects.toThrow("failed"); + expect(useVcs.getState().repo).toBe("/p/a"); +}); + +it("skips tags, groups and each repository's current branch", async () => { + const selected = [ + target("main", "/p/a", { current: true }), + target("other", "/p/b", { current: true }), + { ref: "v1", repo: "/p/a" }, + {}, + target("other", "/p/a"), + target("main", "/p/b"), + ]; + expect(selected.map((t) => canDeleteBranch("ref" in t ? t : null))).toEqual([false, false, false, false, true, true]); + await deleteBranches(selected); + expect(mocks.local.mock.calls.map(([title]) => title)).toEqual(["Delete branch other", "Delete branch main"]); +}); + +it("does nothing for only current branches or non-branch rows", async () => { + await deleteBranches([target("main", "/p/a", { current: true }), { ref: "v1" }, {}]); + expect(mocks.local).not.toHaveBeenCalled(); + expect(mocks.remote).not.toHaveBeenCalled(); + expect(mocks.confirm).not.toHaveBeenCalled(); + expect(mocks.focus).not.toHaveBeenCalled(); + expect(branchMenu(branch("main", { current: true }), "main").some((i) => "label" in i && i.label === "Delete")).toBe( + false, + ); +}); + +it.each([1, 2])("cancelling %s remote branches leaves the whole mixed selection untouched", async (count) => { + mocks.confirm.mockResolvedValue(false); + await deleteBranches([ + target("local"), + ...Array.from({ length: count }, (_, i) => target(`origin/r${i}`, "/p/b", { remote: true })), + ]); + expect(mocks.confirm).toHaveBeenCalledTimes(1); + expect(mocks.local).not.toHaveBeenCalled(); + expect(mocks.remote).not.toHaveBeenCalled(); + expect(mocks.focus).not.toHaveBeenCalled(); +}); + +it("confirms once for remote branches across repositories and deletes in the right repository", async () => { + const repos: (string | null)[] = []; + mocks.remote.mockImplementation(async () => { + repos.push(useVcs.getState().repo); + return true; + }); + await deleteBranches([ + target("origin/one", "/p/a", { remote: true }), + target("origin/two", "/p/b", { remote: true }), + ]); + expect(mocks.confirm).toHaveBeenCalledTimes(1); + expect(mocks.remote.mock.calls.map(([op]) => op.branch)).toEqual(["origin/one", "origin/two"]); + expect(repos).toEqual(["/p/a", "/p/b"]); + expect(useVcs.getState().repo).toBe("/p/a"); +}); + +it("the Delete action uses the whole pane selection without an explicit argument", async () => { + const dispose = registerLogPaneActions(); + try { + useBranchesPane.setState({ selected: [target("one"), target("two")] }); + await getAction("vcs.log.branches.delete")!.run({ ctx: {} }); + await vi.waitFor(() => expect(mocks.local).toHaveBeenCalledTimes(2)); + } finally { + dispose(); + } +}); + +it("Delete Tracked Branch keeps the repository captured before focus was restored", async () => { + mocks.local.mockResolvedValue({ name: "one", tip: "abc", upstream: "origin/one", unmerged: false }); + await deleteBranches([target("one", "/p/b")]); + expect(useVcs.getState().repo).toBe("/p/a"); + const notification = mocks.notify.mock.calls[0]![0]; + notification.actions.find((a: { label: string }) => a.label === "Delete Tracked Branch").run(); + await vi.waitFor(() => + expect(mocks.remote).toHaveBeenCalledWith({ op: "deleteBranch", branch: "origin/one" }, "origin/one", ["/p/b"]), + ); +}); + +it("View Commits keeps the deleted branch's repository after focus was restored", async () => { + mocks.local.mockResolvedValue({ name: "one", tip: "abc", upstream: null, unmerged: true }); + await deleteBranches([target("one", "/p/b")]); + const notification = mocks.notify.mock.calls[0]![0]; + notification.actions.find((a: { label: string }) => a.label === "View Commits").run(); + expect(mocks.range).toHaveBeenCalledWith("Commits of one", "HEAD", "abc", "/p/b"); +}); + +it("restores the primary repository when it was focused implicitly", async () => { + useVcs.setState({ repo: null, roots: ["/p/a", "/p/b"] }); + await deleteBranches([target("one", "/p/b")]); + expect(useVcs.getState().repo).toBe("/p/a"); +}); diff --git a/src/features/vcs/__tests__/logPanes.test.tsx b/src/features/vcs/__tests__/logPanes.test.tsx index 58b945216..0e8628470 100644 --- a/src/features/vcs/__tests__/logPanes.test.tsx +++ b/src/features/vcs/__tests__/logPanes.test.tsx @@ -5,6 +5,11 @@ import type { Branch, Commit } from "@/bindings"; const vcsCommitFiles = vi.fn(); const vcsDiffNumstat = vi.fn(); const vcsTags = vi.fn(); +const { deleteBranches } = vi.hoisted(() => ({ deleteBranches: vi.fn() })); +vi.mock("../branchOps", async (original) => ({ + ...(await original()), + deleteBranches, +})); vi.mock("@/platform/ipc", async (importOriginal) => { const real = await importOriginal(); @@ -22,6 +27,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { }; }); +import { canDeleteBranch } from "../branchOps"; import { buildBranchTree, filterBranchTree } from "../branches"; import { buildFileTree } from "../fileTree"; import { @@ -34,7 +40,7 @@ import { toLogChanges, useLogChanges, } from "../logChanges"; -import { canDeleteBranch, canUpdateBranch, useBranchesPane } from "../logPaneActions"; +import { canUpdateBranch, useBranchesPane } from "../logPaneActions"; import { BranchesPanel, CommitDetails, changesMenu, graphOptionsMenu } from "../GitLog"; import { LogSettingsPage } from "../LogSettingsPage"; import { DEFAULT_LOG_OPTIONS, useLogOptions } from "../logOptions"; @@ -75,6 +81,7 @@ afterEach(() => { vcsCommitFiles.mockReset(); vcsDiffNumstat.mockReset(); vcsTags.mockReset(); + deleteBranches.mockReset(); }); describe("changed files model", () => { @@ -213,6 +220,32 @@ describe("Branches pane", () => { expect(canUpdateBranch({ ref: "origin/x", branch: b("origin/x", { remote: true }) })).toBe(false); }); + it.each([true, false])("context Delete respects membership in the selection (%s)", async (inside) => { + vcsTags.mockResolvedValue({ status: "ok", data: ["v1"] }); + useVcs.setState({ + root: "/p", + repo: "/p", + branchesLoaded: true, + branches: [b("main", { current: true }), b("one"), b("two"), b("outside")], + }); + render(); + fireEvent.doubleClick(await screen.findByText("Tags")); + fireEvent.mouseDown(screen.getByText("one")); + fireEvent.mouseDown(screen.getByText("two"), { metaKey: true }); + fireEvent.mouseDown(await screen.findByText("v1"), { metaKey: true }); + fireEvent.mouseDown(screen.getByText("Local"), { metaKey: true }); + fireEvent.contextMenu(screen.getByText(inside ? "two" : "outside")); + const { useContextMenuStore, hideContextMenu } = await import("@/ui/Menu"); + const item = useContextMenuStore.getState().menu!.items.find((i) => "label" in i && i.label === "Delete"); + expect(item).toBeDefined(); + if (item && "run" in item) item.run?.(); + await waitFor(() => expect(deleteBranches).toHaveBeenCalledTimes(1)); + expect(deleteBranches.mock.calls[0]![0].map((t: { ref: string }) => t.ref)).toEqual( + inside ? ["one", "two", "v1"] : ["outside"], + ); + hideContextMenu(); + }); + it("has a vertical toolbar acting on the selected branch, and shows tags", async () => { vcsTags.mockResolvedValue({ status: "ok", data: ["v1"] }); useVcs.setState({ @@ -226,6 +259,12 @@ describe("Branches pane", () => { expect(screen.getByRole("toolbar", { name: "Branches" })).toHaveAttribute("aria-orientation", "vertical"); expect(screen.getByPlaceholderText("Branch or tag")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Delete Branch" })).toBeDisabled(); + fireEvent.mouseDown(screen.getByText("main")); + expect(screen.getByRole("button", { name: "Delete Branch" })).toBeDisabled(); + fireEvent.contextMenu(screen.getByText("main")); + const { useContextMenuStore, hideContextMenu } = await import("@/ui/Menu"); + expect(useContextMenuStore.getState().menu!.items.some((i) => "label" in i && i.label === "Delete")).toBe(false); + hideContextMenu(); fireEvent.mouseDown(screen.getByText("feature")); expect(useBranchesPane.getState().selected.map((t) => t.ref)).toEqual(["feature"]); expect(screen.getByRole("button", { name: "Delete Branch" })).toBeEnabled(); diff --git a/src/features/vcs/branchMenu.ts b/src/features/vcs/branchMenu.ts index 1805f6440..b0f23fe94 100644 --- a/src/features/vcs/branchMenu.ts +++ b/src/features/vcs/branchMenu.ts @@ -10,6 +10,7 @@ import { checkoutDetached, checkoutInRepos, deleteBranch, + deleteBranches, deleteInRepos, deleteRemoteBranch, deleteTag, @@ -46,7 +47,13 @@ async function pullInto(remoteBranch: string, rebase: boolean) { ); } -export function branchMenu(b: Branch, current: string | null, done: () => void = () => {}): MenuItem[] { +/** The Branches pane's context menu: `selected` carries the multi-selection, which Delete acts on as a whole. */ +export function branchMenu( + b: Branch, + current: string | null, + done: () => void = () => {}, + selected: readonly { repo?: string; branch?: Branch }[] = [], +): MenuItem[] { const run = (fn: () => unknown) => () => { done(); void fn(); @@ -110,7 +117,9 @@ export function branchMenu(b: Branch, current: string | null, done: () => void = label: "Delete", danger: true, icon: "trash", - run: run(() => (b.remote ? deleteRemoteBranch(b.name) : deleteBranch(b.name))), + run: run(() => + selected.length ? deleteBranches(selected) : b.remote ? deleteRemoteBranch(b.name) : deleteBranch(b.name), + ), }, ); return items; diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index 16a5693ac..aa489ced9 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -10,7 +10,7 @@ import { showCommitRange } from "./CompareBranches"; import { confirmDialog, promptText } from "./dialogs"; import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName"; import { runOp, runRemote, saveAll } from "./ops"; -import { conflictedFiles, currentRepo, refreshNow, repoName, useVcs } from "./store"; +import { conflictedFiles, currentRepo, currentRepoRoot, focusRepo, refreshNow, repoName, useVcs } from "./store"; import { tx } from "@/platform/i18n"; const recentKey = () => `openidea.vcs.recentBranches:${useVcs.getState().root ?? ""}`; @@ -309,7 +309,7 @@ export async function deleteBranch(name: string) { }, }, ...(deleted.unmerged - ? [{ label: "View Commits", run: () => showCommitRange(`Commits of ${name}`, "HEAD", deleted.tip) }] + ? [{ label: "View Commits", run: () => showCommitRange(`Commits of ${name}`, "HEAD", deleted.tip, repo) }] : []), ...(tracked && !otherTracking ? [ @@ -317,7 +317,7 @@ export async function deleteBranch(name: string) { label: "Delete Tracked Branch", run: () => { done(); - void deleteRemoteBranch(tracked); + void deleteRemoteBranch(tracked, repo); }, }, ] @@ -386,7 +386,7 @@ export async function pushTag(tag: string, remote: string) { } /** Deletes a branch on its remote (`git push --delete `); `name` is e.g. `origin/feature`. */ -export async function deleteRemoteBranch(name: string) { +export async function deleteRemoteBranch(name: string, repo = currentRepo()) { const ok = await confirmDialog({ title: "Delete Remote Branch", message: tx`Delete branch '${shortRemoteName(name)}' on the remote? The remote-tracking branch '${name}' is removed as well.`, @@ -394,7 +394,50 @@ export async function deleteRemoteBranch(name: string) { danger: true, }); if (!ok) return; - await runRemote({ op: "deleteBranch", branch: name }, name); + await runRemote({ op: "deleteBranch", branch: name }, name, [repo]); +} + +/** Delete is offered for branches other than the current one in their repository. */ +export function canDeleteBranch(t: { ref?: string; repo?: string; branch?: Branch } | null): boolean { + return !!t?.branch && !t.branch.current; +} + +async function confirmRemoteBranches(remote: readonly { branch: Branch }[]): Promise { + if (remote.length === 1) { + const name = remote[0]!.branch.name; + return confirmDialog({ + title: "Delete Remote Branch", + message: tx`Delete branch '${shortRemoteName(name)}' on the remote? The remote-tracking branch '${name}' is removed as well.`, + okLabel: "Delete", + danger: true, + }); + } + const names = remote.map((t) => shortRemoteName(t.branch.name)).join("', '"); + return confirmDialog({ + title: "Delete Remote Branches", + message: tx`Delete the ${remote.length} branches '${names}' on the remote? The remote-tracking branches are removed as well.`, + okLabel: "Delete", + danger: true, + }); +} + +/** Deletes selected branches, skipping current branches and restoring the focused repository. */ +export async function deleteBranches(targets: readonly { ref?: string; repo?: string; branch?: Branch }[]) { + const list = targets.filter((t): t is { repo?: string; branch: Branch } => canDeleteBranch(t)); + if (!list.length) return; + const previous = currentRepoRoot(); + const remote = list.filter((t) => t.branch.remote); + if (remote.length && !(await confirmRemoteBranches(remote))) return; + try { + for (const t of list) { + const repo = t.repo ?? previous; + if (repo && repo !== currentRepo()) await focusRepo(repo); + if (t.branch.remote) await runRemote({ op: "deleteBranch", branch: t.branch.name }, t.branch.name); + else await deleteBranch(t.branch.name); + } + } finally { + if (previous && previous !== currentRepo()) await focusRepo(previous); + } } /** Checkout and Rebase onto Current: checks out `name`, then rebases it onto the branch that was current. */ diff --git a/src/features/vcs/logPaneActions.ts b/src/features/vcs/logPaneActions.ts index 093fdaa49..e70aabf8c 100644 --- a/src/features/vcs/logPaneActions.ts +++ b/src/features/vcs/logPaneActions.ts @@ -7,7 +7,7 @@ import { create } from "zustand"; import type { Branch } from "@/bindings"; import { registerActions, type Action } from "@/platform/actions"; import { notifications, toolWindowService } from "@/workbench/contributions"; -import { deleteBranch, deleteRemoteBranch, newBranch, toggleFavoriteBranch, updateBranch } from "./branchOps"; +import { deleteBranches, newBranch, toggleFavoriteBranch, updateBranch } from "./branchOps"; import { changesArg, compareChangeWithLocal, @@ -38,6 +38,13 @@ function branchArg(arg: unknown): BranchTarget | null { return useBranchesPane.getState().selected[0] ?? null; } +/** The action's branches: an array argument as a whole, otherwise the single one or the pane's selection. */ +function branchTargets(arg: unknown): BranchTarget[] { + if (Array.isArray(arg)) return arg.filter((t): t is BranchTarget => !!t && typeof t === "object" && "ref" in t); + if (arg && typeof arg === "object" && "ref" in arg) return [arg as BranchTarget]; + return useBranchesPane.getState().selected; +} + const withBranch = (fn: (b: Branch) => unknown): Action["run"] => async ({ arg }) => { @@ -47,11 +54,6 @@ const withBranch = if (b) void fn(b); }; -/** Delete is offered for branches other than the current one. */ -export function canDeleteBranch(t: BranchTarget | null): boolean { - return !!t?.branch && !t.branch.current; -} - /** Update fast-forwards a local branch with an upstream (or updates the project for the current one). */ export function canUpdateBranch(t: BranchTarget | null): boolean { return !!t?.branch && !t.branch.remote && (t.branch.current || !!t.branch.upstream); @@ -117,10 +119,7 @@ export function registerLogPaneActions(): () => void { icon: "trash", when: VCS, hidden: true, - run: withBranch((b) => { - if (b.current) return; - return b.remote ? deleteRemoteBranch(b.name) : deleteBranch(b.name); - }), + run: ({ arg }) => deleteBranches(branchTargets(arg)), }, { id: "vcs.log.branches.toggleFavorite", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 00bbd701f..7af553708 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,6 @@ { + "Delete Remote Branches": "Delete Remote Branches", + "Delete the {length} branches '{names}' on the remote? The remote-tracking branches are removed as well.": "Delete the {length} branches '{names}' on the remote? The remote-tracking branches are removed as well.", "Add or Remove Caret": "Add or Remove Caret", "Select Python Environment Folder": "Select Python Environment Folder", "Checking interpreter": "Checking interpreter", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 29e4477bd..1d73af218 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -763,6 +763,7 @@ "Delete Docker Object…": "删除 Docker 对象…", "Delete Keymap": "删除按键映射", "Delete Remote Branch": "删除远程分支", + "Delete Remote Branches": "删除远程分支", "Delete Rows": "删除行", "Delete Shelved Changes": "删除已搁置的更改", "Delete all rows of": "删除以下表的所有行:", @@ -4444,6 +4445,7 @@ "Create New Branch from {from}": "从 {from} 创建新分支", "Rename Branch {name}": "重命名分支 {name}", "Delete branch '{name}' on the remote? The remote-tracking branch '{tracking}' is removed as well.": "是否删除远程上的分支 '{name}'?远程跟踪分支 '{tracking}' 也将被移除。", + "Delete the {length} branches '{names}' on the remote? The remote-tracking branches are removed as well.": "是否删除远程上的 {length} 个分支 '{names}'?远程跟踪分支也将被移除。", "New branch name (in {count} repositories):": "新分支名称(在 {count} 个仓库中):", "Delete branch '{name}' in {count} repositories? Unmerged commits are discarded.": "是否删除 {count} 个仓库中的分支 '{name}'?未合并的提交将被丢弃。", "Your local changes to the following files would be overwritten by checkout:\n{files}\n\nSmart Checkout can stash the changes, checkout and unstash them after that.": "签出将覆盖以下文件的本地更改:\n{files}\n\n智能签出可以储藏这些更改,签出后再取消储藏。",